mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add watchedAt metadata to watch history pointers
This commit is contained in:
1 parent
5f88e85835
commit
ad3f9cb200
2 files changed
+267
-5
No files matched your search
+105
-3
@@ -112,8 +112,18 @@ function clonePointerItem(pointer) {
|
||||
typeof pointer.relay === "string" && pointer.relay.trim()
|
||||
? pointer.relay.trim()
|
||||
: null;
|
||||
const watchedAtRaw = Number.isFinite(pointer.watchedAt)
|
||||
? pointer.watchedAt
|
||||
: null;
|
||||
const watchedAt =
|
||||
watchedAtRaw !== null ? Math.max(0, Math.floor(watchedAtRaw)) : null;
|
||||
|
||||
return { type, value, relay };
|
||||
const cloned = { type, value, relay };
|
||||
if (watchedAt !== null) {
|
||||
cloned.watchedAt = watchedAt;
|
||||
}
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function normalizePointerTag(tag) {
|
||||
@@ -1091,7 +1101,12 @@ class NostrClient {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
normalizedItems.push(pointer);
|
||||
const watchedAtRaw = Number.isFinite(pointer.watchedAt)
|
||||
? pointer.watchedAt
|
||||
: null;
|
||||
const watchedAt =
|
||||
watchedAtRaw !== null ? Math.max(0, Math.floor(watchedAtRaw)) : 0;
|
||||
normalizedItems.push({ ...pointer, watchedAt });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1175,6 +1190,9 @@ class NostrClient {
|
||||
type: item.type,
|
||||
value: item.value,
|
||||
relay: item.relay || null,
|
||||
watchedAt: Number.isFinite(item.watchedAt)
|
||||
? Math.max(0, Math.floor(item.watchedAt))
|
||||
: 0,
|
||||
})),
|
||||
savedAt: entry.savedAt,
|
||||
};
|
||||
@@ -1570,6 +1588,9 @@ class NostrClient {
|
||||
type: item.type,
|
||||
value: item.value,
|
||||
relay: item.relay || null,
|
||||
watchedAt: Number.isFinite(item.watchedAt)
|
||||
? Math.max(0, Math.floor(item.watchedAt))
|
||||
: 0,
|
||||
}));
|
||||
|
||||
const maxBytesCandidate = Number.isFinite(this.watchHistoryPayloadMaxBytes)
|
||||
@@ -2069,6 +2090,15 @@ class NostrClient {
|
||||
return { ok: false, error: "invalid-pointer" };
|
||||
}
|
||||
|
||||
if (Number.isFinite(normalizedPointer.watchedAt)) {
|
||||
normalizedPointer.watchedAt = Math.max(
|
||||
0,
|
||||
Math.floor(normalizedPointer.watchedAt)
|
||||
);
|
||||
} else {
|
||||
normalizedPointer.watchedAt = Date.now();
|
||||
}
|
||||
|
||||
const actorPubkey = await this.ensureSessionActor();
|
||||
if (!actorPubkey) {
|
||||
return { ok: false, error: "missing-actor" };
|
||||
@@ -2084,6 +2114,57 @@ class NostrClient {
|
||||
return this.publishWatchHistorySnapshot(actorPubkey, candidates, existingEntry);
|
||||
}
|
||||
|
||||
async removeWatchHistoryItem(pointerOrKey) {
|
||||
if (!this.pool) {
|
||||
return { ok: false, error: "nostr-uninitialized" };
|
||||
}
|
||||
|
||||
const actorPubkey = await this.ensureSessionActor();
|
||||
if (!actorPubkey) {
|
||||
return { ok: false, error: "missing-actor" };
|
||||
}
|
||||
|
||||
await this.fetchWatchHistory(actorPubkey);
|
||||
|
||||
const entry = this.watchHistoryCache.get(actorPubkey);
|
||||
if (!entry) {
|
||||
return { ok: false, error: "missing-entry" };
|
||||
}
|
||||
|
||||
let targetKey = "";
|
||||
if (typeof pointerOrKey === "string" && pointerOrKey.trim()) {
|
||||
targetKey = pointerOrKey.trim().toLowerCase();
|
||||
} else if (pointerOrKey) {
|
||||
const normalizedPointer = normalizePointerInput(pointerOrKey);
|
||||
targetKey = pointerKey(normalizedPointer);
|
||||
}
|
||||
|
||||
if (!targetKey) {
|
||||
return { ok: false, error: "invalid-pointer" };
|
||||
}
|
||||
|
||||
const filteredItems = entry.items.filter((item) => {
|
||||
const key = pointerKey(item);
|
||||
return key && key !== targetKey;
|
||||
});
|
||||
|
||||
if (filteredItems.length === entry.items.length) {
|
||||
return { ok: false, error: "pointer-not-found" };
|
||||
}
|
||||
|
||||
const result = await this.publishWatchHistorySnapshot(
|
||||
actorPubkey,
|
||||
filteredItems,
|
||||
entry
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return { ...result, removedKey: targetKey };
|
||||
}
|
||||
|
||||
async fetchWatchHistory(pubkeyOrSession) {
|
||||
let actor =
|
||||
typeof pubkeyOrSession === "string" && pubkeyOrSession.trim()
|
||||
@@ -2915,7 +2996,25 @@ class NostrClient {
|
||||
}
|
||||
const cachedVideo = entry.resolvedVideos.get(key);
|
||||
if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) {
|
||||
deliverableVideos.push(cachedVideo);
|
||||
const pointerClone = clonePointerItem(pointer);
|
||||
const watchedAt = Number.isFinite(pointerClone?.watchedAt)
|
||||
? pointerClone.watchedAt
|
||||
: 0;
|
||||
const videoWithHistory = {
|
||||
...cachedVideo,
|
||||
watchHistory: {
|
||||
key,
|
||||
watchedAt,
|
||||
pointer:
|
||||
pointerClone || {
|
||||
type: pointer.type,
|
||||
value: pointer.value,
|
||||
relay: pointer.relay || null,
|
||||
watchedAt,
|
||||
},
|
||||
},
|
||||
};
|
||||
deliverableVideos.push(videoWithHistory);
|
||||
entry.delivered.add(key);
|
||||
}
|
||||
}
|
||||
@@ -4080,3 +4179,6 @@ export const recordVideoView = (...args) =>
|
||||
|
||||
export const updateWatchHistoryList = (...args) =>
|
||||
nostrClient.updateWatchHistoryList(...args);
|
||||
|
||||
export const removeWatchHistoryItem = (...args) =>
|
||||
nostrClient.removeWatchHistoryItem(...args);
|
||||
@@ -42,6 +42,7 @@ function createPublishingClient(actorPubkey) {
|
||||
},
|
||||
};
|
||||
},
|
||||
list: async () => [],
|
||||
};
|
||||
client.relays = ["wss://unit.test"];
|
||||
client.pubkey = "";
|
||||
@@ -72,19 +73,23 @@ const { client: publishingClient, publishedEvents, payloads } =
|
||||
publishingClient.watchHistoryPayloadMaxBytes = 400;
|
||||
publishingClient.watchHistoryFetchEventLimit = 10;
|
||||
|
||||
const baseWatchedAt = 1_700_000_000_000;
|
||||
const longPointers = Array.from({ length: 8 }, (_, index) => {
|
||||
const relay = index % 3 === 0 ? "wss://relay.unit" : null;
|
||||
const watchedAt = baseWatchedAt + index * 60_000;
|
||||
if (index % 2 === 0) {
|
||||
return {
|
||||
type: "e",
|
||||
value: `event-${index}-${"x".repeat(90)}`,
|
||||
relay,
|
||||
watchedAt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "a",
|
||||
value: `30078:${ACTOR}:history-${index}-${"y".repeat(60)}`,
|
||||
relay,
|
||||
watchedAt,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -208,13 +213,15 @@ assert.deepEqual(
|
||||
type: item.type,
|
||||
value: item.value,
|
||||
relay: item.relay || null,
|
||||
watchedAt: item.watchedAt,
|
||||
})),
|
||||
longPointers.map((item) => ({
|
||||
type: item.type,
|
||||
value: item.value,
|
||||
relay: item.relay || null,
|
||||
watchedAt: item.watchedAt,
|
||||
})),
|
||||
"chunked fetch should preserve pointer order"
|
||||
"chunked fetch should preserve pointer order and timestamps"
|
||||
);
|
||||
|
||||
if (typeof localStorage !== "undefined") {
|
||||
@@ -295,6 +302,22 @@ assert.equal(
|
||||
"fresh client should rebuild full watch history list"
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
rebuilt.items.map((item) => ({
|
||||
type: item.type,
|
||||
value: item.value,
|
||||
relay: item.relay || null,
|
||||
watchedAt: item.watchedAt,
|
||||
})),
|
||||
longPointers.map((item) => ({
|
||||
type: item.type,
|
||||
value: item.value,
|
||||
relay: item.relay || null,
|
||||
watchedAt: item.watchedAt,
|
||||
})),
|
||||
"rebuilt snapshot should preserve timestamps"
|
||||
);
|
||||
|
||||
assert(
|
||||
observedFilters.length >= 2,
|
||||
"fetch should include a follow-up query for chunk events"
|
||||
@@ -398,6 +421,121 @@ try {
|
||||
|
||||
localStorage.clear();
|
||||
|
||||
const UPDATE_ACTOR = `${ACTOR}-update`;
|
||||
const { client: updateClient } = createPublishingClient(UPDATE_ACTOR);
|
||||
const updateOriginalDateNow = Date.now;
|
||||
try {
|
||||
const updateTimestamp = 1_701_111_111_111;
|
||||
Date.now = () => updateTimestamp;
|
||||
|
||||
const updateResult = await updateClient.updateWatchHistoryList({
|
||||
type: "e",
|
||||
value: "update-pointer",
|
||||
relay: null,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
updateResult.ok,
|
||||
true,
|
||||
"updateWatchHistoryList should publish when adding a new pointer"
|
||||
);
|
||||
const updateEntry = updateClient.watchHistoryCache.get(UPDATE_ACTOR);
|
||||
assert(updateEntry, "updateWatchHistoryList should cache the new entry");
|
||||
assert.equal(
|
||||
updateEntry.items[0].watchedAt,
|
||||
updateTimestamp,
|
||||
"cached entry should record watchedAt from Date.now()"
|
||||
);
|
||||
assert.equal(
|
||||
updateResult.items[0].watchedAt,
|
||||
updateTimestamp,
|
||||
"returned publish result should include the watchedAt timestamp"
|
||||
);
|
||||
} finally {
|
||||
Date.now = updateOriginalDateNow;
|
||||
}
|
||||
|
||||
const REMOVE_ACTOR = `${ACTOR}-remove`;
|
||||
const {
|
||||
client: removalClient,
|
||||
payloads: removalPayloads,
|
||||
} = createPublishingClient(REMOVE_ACTOR);
|
||||
|
||||
const removalPointers = [
|
||||
{
|
||||
type: "e",
|
||||
value: "remove-first",
|
||||
relay: null,
|
||||
watchedAt: 1_701_222_000_000,
|
||||
},
|
||||
{
|
||||
type: "e",
|
||||
value: "remove-second",
|
||||
relay: null,
|
||||
watchedAt: 1_701_222_100_000,
|
||||
},
|
||||
];
|
||||
|
||||
await removalClient.publishWatchHistorySnapshot(
|
||||
REMOVE_ACTOR,
|
||||
removalPointers
|
||||
);
|
||||
|
||||
const removalKey = `e:${removalPointers[1].value.trim().toLowerCase()}`;
|
||||
const removalResult = await removalClient.removeWatchHistoryItem(removalKey);
|
||||
|
||||
assert.equal(removalResult.ok, true, "removeWatchHistoryItem should succeed");
|
||||
assert.equal(
|
||||
removalResult.removedKey,
|
||||
removalKey,
|
||||
"removeWatchHistoryItem should echo the removed pointer key"
|
||||
);
|
||||
|
||||
const latestRemovalPayload = removalPayloads[removalPayloads.length - 1];
|
||||
assert.equal(
|
||||
latestRemovalPayload.items.length,
|
||||
1,
|
||||
"removal republish should trim the pointer list"
|
||||
);
|
||||
assert.equal(
|
||||
latestRemovalPayload.items[0].value,
|
||||
removalPointers[0].value,
|
||||
"republished payload should only include remaining pointer"
|
||||
);
|
||||
assert.equal(
|
||||
latestRemovalPayload.items[0].watchedAt,
|
||||
removalPointers[0].watchedAt,
|
||||
"republished payload should preserve watchedAt"
|
||||
);
|
||||
|
||||
const removalEntry = removalClient.watchHistoryCache.get(REMOVE_ACTOR);
|
||||
assert.equal(
|
||||
removalEntry.items.length,
|
||||
1,
|
||||
"watch history cache should drop the removed pointer"
|
||||
);
|
||||
assert.equal(
|
||||
removalEntry.items[0].value,
|
||||
removalPointers[0].value,
|
||||
"remaining cached pointer should match the survivor"
|
||||
);
|
||||
assert.equal(
|
||||
removalEntry.items[0].watchedAt,
|
||||
removalPointers[0].watchedAt,
|
||||
"remaining cached pointer should retain watchedAt"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
removalResult.items.map((item) => item.value).join(","),
|
||||
removalPointers.slice(0, 1).map((item) => item.value).join(","),
|
||||
"removeWatchHistoryItem result should reflect updated items"
|
||||
);
|
||||
assert.equal(
|
||||
removalResult.items[0].watchedAt,
|
||||
removalPointers[0].watchedAt,
|
||||
"removeWatchHistoryItem result should preserve watchedAt"
|
||||
);
|
||||
|
||||
const VIDEO_POINTER_AUTHOR = `${ACTOR}-video-author`;
|
||||
const VIDEO_POINTER_IDENTIFIER = "pointer-video-identifier";
|
||||
const videoPointer = {
|
||||
@@ -438,6 +576,8 @@ pointerClient.pool = {
|
||||
|
||||
const resolvedFromPointer = await pointerClient.resolveWatchHistory(1);
|
||||
|
||||
const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`;
|
||||
|
||||
assert.equal(
|
||||
resolvedFromPointer.length,
|
||||
1,
|
||||
@@ -454,7 +594,23 @@ assert.equal(
|
||||
"resolved video should parse title from the event payload"
|
||||
);
|
||||
|
||||
const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`;
|
||||
assert(resolvedFromPointer[0].watchHistory, "resolved video should include watchHistory metadata");
|
||||
assert.equal(
|
||||
resolvedFromPointer[0].watchHistory.key,
|
||||
pointerKey,
|
||||
"watchHistory metadata should expose the pointer key"
|
||||
);
|
||||
assert.equal(
|
||||
resolvedFromPointer[0].watchHistory.pointer.value,
|
||||
videoPointer.value,
|
||||
"watchHistory metadata should include the pointer payload"
|
||||
);
|
||||
assert.equal(
|
||||
resolvedFromPointer[0].watchHistory.pointer.watchedAt,
|
||||
pointerEntry.items[0].watchedAt,
|
||||
"watchHistory metadata should preserve watchedAt"
|
||||
);
|
||||
|
||||
const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR);
|
||||
assert(
|
||||
cachedEntry.resolvedVideos.has(pointerKey),
|
||||
@@ -511,6 +667,10 @@ assert.equal(
|
||||
videoEvent.id,
|
||||
"cached resolve should reuse the stored video payload"
|
||||
);
|
||||
assert(
|
||||
cachedResolve[0].watchHistory?.watchedAt === pointerEntry.items[0].watchedAt,
|
||||
"cached resolve should retain watchHistory metadata"
|
||||
);
|
||||
|
||||
const originalDateNow = Date.now;
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user