Improve watch history caching and reuse

This commit is contained in:
thePR0M3TH3AN committed 2025-09-30 20:09:23 -04:00
1 parent 5e81bc9aef
commit 6039ad2fd7
3 files changed
+163 -42

No files matched your search

+4
View File
@@ -950,6 +950,10 @@ export function createWatchHistoryRenderer(config = {}) {
state.actor = beforeResult.actor.trim();
}
if (state.actor) {
nostrClient.resetWatchHistoryProgress(state.actor);
}
let fingerprint;
if (typeof fingerprintOverride !== "undefined") {
fingerprint = fingerprintOverride;
+118 -40
View File
@@ -1102,9 +1102,15 @@ class NostrClient {
resolved: previousEntry?.resolved
? new Map(previousEntry.resolved)
: new Map(),
resolvedVideos: previousEntry?.resolvedVideos
? new Map(previousEntry.resolvedVideos)
: new Map(),
resolving: previousEntry?.resolving
? new Set(previousEntry.resolving)
: new Set(),
delivered: previousEntry?.delivered instanceof Set
? new Set(previousEntry.delivered)
: new Set(),
};
const validKeys = new Set(entry.items.map((item) => pointerKey(item)));
@@ -1113,11 +1119,21 @@ class NostrClient {
entry.resolved.delete(key);
}
}
for (const key of entry.resolvedVideos.keys()) {
if (!validKeys.has(key)) {
entry.resolvedVideos.delete(key);
}
}
for (const key of [...entry.resolving]) {
if (!validKeys.has(key)) {
entry.resolving.delete(key);
}
}
for (const key of [...entry.delivered]) {
if (!validKeys.has(key)) {
entry.delivered.delete(key);
}
}
const previousChunkPointers = previousEntry?.chunkPointers;
const headPointer = previousChunkPointers?.head
@@ -1214,6 +1230,40 @@ class NostrClient {
return `${pointerId}:${pointerCreated}:${itemKeys}`;
}
resetWatchHistoryProgress(pubkeyOrSession = null) {
let actor =
typeof pubkeyOrSession === "string" && pubkeyOrSession.trim()
? pubkeyOrSession.trim()
: "";
if (!actor) {
if (this.sessionActor?.pubkey) {
actor = this.sessionActor.pubkey.trim();
} else if (typeof this.pubkey === "string" && this.pubkey.trim()) {
actor = this.pubkey.trim();
}
}
if (!actor) {
return;
}
const entry = this.watchHistoryCache.get(actor);
if (!entry) {
return;
}
if (!(entry.delivered instanceof Set)) {
entry.delivered = new Set();
} else {
entry.delivered.clear();
}
if (entry.resolving instanceof Set) {
entry.resolving.clear();
}
}
async encryptWatchHistoryPayload(actorPubkey, payload) {
const normalizedActor =
typeof actorPubkey === "string" && actorPubkey.trim()
@@ -2518,7 +2568,10 @@ class NostrClient {
}
for (const key of Array.from(entry.resolved.keys())) {
if (!key || !key.startsWith("a:")) {
if (
!key ||
!key.startsWith(`a:${WATCH_HISTORY_KIND}:`)
) {
continue;
}
if (!chunkPointerKeys.has(key)) {
@@ -2570,6 +2623,13 @@ class NostrClient {
return [];
}
if (!(entry.resolvedVideos instanceof Map)) {
entry.resolvedVideos = new Map();
}
if (!(entry.delivered instanceof Set)) {
entry.delivered = new Set();
}
const parsedBatchSize = Number(batchSize);
const normalizedBatchSize =
Number.isFinite(parsedBatchSize) && parsedBatchSize > 0
@@ -2691,12 +2751,18 @@ class NostrClient {
if (!key) {
return false;
}
if (entry.resolved.has(key)) {
if (entry.delivered.has(key)) {
return false;
}
if (entry.resolving.has(key)) {
return false;
}
if (entry.resolvedVideos.has(key)) {
const cachedVideo = entry.resolvedVideos.get(key);
if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) {
return false;
}
}
return true;
});
@@ -2727,56 +2793,53 @@ class NostrClient {
const videoBatch = available.slice(0, effectiveBatchSize);
const batch = [...videoBatch, ...chunkAvailable];
if (!batch.length) {
return [];
}
batch.forEach((item) => {
const key = pointerKey(item);
if (key) {
entry.resolving.add(key);
}
});
const relaySet = new Set(
Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS
);
const filters = [];
for (const pointer of batch) {
if (pointer.relay) {
relaySet.add(pointer.relay);
}
if (pointer.type === "e") {
filters.push({ ids: [pointer.value] });
} else if (pointer.type === "a") {
const [kindStr, pubkey, identifier] = pointer.value.split(":");
const kind = Number(kindStr);
if (Number.isFinite(kind) && pubkey) {
const filter = { kinds: [kind], authors: [pubkey] };
if (identifier) {
filter["#d"] = [identifier];
if (batch.length) {
batch.forEach((item) => {
const key = pointerKey(item);
if (key) {
entry.resolving.add(key);
}
});
for (const pointer of batch) {
if (pointer.relay) {
relaySet.add(pointer.relay);
}
if (pointer.type === "e") {
filters.push({ ids: [pointer.value] });
} else if (pointer.type === "a") {
const [kindStr, pubkey, identifier] = pointer.value.split(":");
const kind = Number(kindStr);
if (Number.isFinite(kind) && pubkey) {
const filter = { kinds: [kind], authors: [pubkey] };
if (identifier) {
filter["#d"] = [identifier];
}
filters.push(filter);
}
filters.push(filter);
}
}
}
if (!filters.length) {
batch.forEach((item) => entry.resolving.delete(pointerKey(item)));
return [];
}
let events = [];
try {
events = await this.pool.list(Array.from(relaySet), filters);
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to resolve watch history batch:", error);
if (filters.length) {
try {
events = await this.pool.list(Array.from(relaySet), filters);
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to resolve watch history batch:", error);
}
}
} else if (batch.length) {
batch.forEach((item) => entry.resolving.delete(pointerKey(item)));
}
const resolvedVideos = [];
const pointerMatches = new Map();
for (const evt of events || []) {
@@ -2830,8 +2893,7 @@ class NostrClient {
this.activeMap.set(activeKey, video);
}
}
entry.resolved.set(key, video);
resolvedVideos.push(video);
entry.resolvedVideos.set(key, video);
}
} catch (err) {
if (isDevMode) {
@@ -2842,7 +2904,23 @@ class NostrClient {
entry.resolving.delete(key);
}
return resolvedVideos;
const deliverableVideos = [];
for (const pointer of videoCandidates) {
if (deliverableVideos.length >= effectiveBatchSize) {
break;
}
const key = pointerKey(pointer);
if (!key || entry.delivered.has(key)) {
continue;
}
const cachedVideo = entry.resolvedVideos.get(key);
if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) {
deliverableVideos.push(cachedVideo);
entry.delivered.add(key);
}
}
return deliverableVideos;
}
async recordVideoView(videoPointer, options = {}) {
+41 -2
View File
@@ -456,12 +456,19 @@ assert.equal(
const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`;
const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR);
assert(cachedEntry.resolved.has(pointerKey), "resolved cache should retain the video");
assert(
cachedEntry.resolvedVideos.has(pointerKey),
"resolved cache should retain the video"
);
assert.equal(
cachedEntry.resolved.get(pointerKey)?.id,
cachedEntry.resolvedVideos.get(pointerKey)?.id,
videoEvent.id,
"resolved cache should store the converted video result"
);
assert(
cachedEntry.delivered.has(pointerKey),
"delivery set should mark the pointer as delivered"
);
assert(
observedResolveFilters.some((filters) =>
@@ -473,6 +480,38 @@ assert(
"resolveWatchHistory should request the video identifier instead of treating it as a chunk"
);
const secondResolve = await pointerClient.resolveWatchHistory(1);
assert.equal(
secondResolve.length,
0,
"subsequent resolve calls should avoid refetching already delivered videos"
);
pointerClient.resetWatchHistoryProgress(ACTOR);
assert.equal(
cachedEntry.delivered.size,
0,
"resetWatchHistoryProgress should clear delivered pointers"
);
pointerClient.pool = {
list: async () => {
throw new Error("cached resolve should not trigger network fetch");
},
};
const cachedResolve = await pointerClient.resolveWatchHistory(1);
assert.equal(
cachedResolve.length,
1,
"reset should allow cached videos to be delivered again"
);
assert.equal(
cachedResolve[0].id,
videoEvent.id,
"cached resolve should reuse the stored video payload"
);
const originalDateNow = Date.now;
try {
const baseTime = 1_700_000_000_000;