Preserve video metadata in watch history payloads

This commit is contained in:
thePR0M3TH3AN committed 2025-10-09 11:31:47 -04:00
1 parent 4e42db2667
commit 507f09dff5
6 files changed
+435 -30

No files matched your search

+1
View File
@@ -3237,6 +3237,7 @@ class Application {
videoElement: videoEl,
pointer,
pointerKey,
video: this.currentVideo,
});
}
+36 -3
View File
@@ -96,16 +96,47 @@ function normalizeHistoryEntry(raw) {
raw?.timestamp,
raw?.created_at,
pointer?.watchedAt,
raw?.metadata?.watchedAt,
pointer?.metadata?.watchedAt,
);
const resumeAt = resolveResumeSeconds(raw);
const completed = resolveCompleted(raw);
const resumeAt = (() => {
const fromPointer = Number.isFinite(pointer?.resumeAt)
? Math.max(0, Math.floor(pointer.resumeAt))
: Number.isFinite(pointer?.metadata?.resumeAt)
? Math.max(0, Math.floor(pointer.metadata.resumeAt))
: null;
if (fromPointer !== null) {
return fromPointer;
}
return resolveResumeSeconds(raw);
})();
const completed =
pointer?.completed === true ||
pointer?.metadata?.completed === true ||
resolveCompleted(raw);
const pointerVideo =
(pointer?.video && isPlainObject(pointer.video) ? pointer.video : null) ||
(pointer?.metadata?.video && isPlainObject(pointer.metadata.video)
? pointer.metadata.video
: null);
const pointerProfile =
(pointer?.profile && isPlainObject(pointer.profile) ? pointer.profile : null) ||
(pointer?.metadata?.profile && isPlainObject(pointer.metadata.profile)
? pointer.metadata.profile
: null);
return {
pointer,
pointerKey: key,
watchedAt,
video: isPlainObject(raw?.video) ? raw.video : null,
video:
isPlainObject(raw?.video) && raw.video
? raw.video
: pointerVideo,
metadata: {
source: "watch-history",
pointerKey: key,
@@ -113,6 +144,8 @@ function normalizeHistoryEntry(raw) {
resumeAt,
completed,
session: raw?.session === true || pointer.session === true,
video: pointerVideo,
profile: pointerProfile,
},
};
}
+224 -18
View File
@@ -1329,6 +1329,117 @@ function pointerKey(pointer) {
return `${type}:${value}`;
}
function cloneVideoMetadata(video) {
if (!video || typeof video !== "object") {
return null;
}
const createdAt = Number.isFinite(video.created_at)
? Math.floor(video.created_at)
: null;
return {
id: typeof video.id === "string" ? video.id : "",
title: typeof video.title === "string" ? video.title : "",
thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
created_at: createdAt,
url: typeof video.url === "string" ? video.url : "",
magnet: typeof video.magnet === "string" ? video.magnet : "",
};
}
function cloneProfileMetadata(profile) {
if (!profile || typeof profile !== "object") {
return null;
}
return {
pubkey: typeof profile.pubkey === "string" ? profile.pubkey : "",
name: typeof profile.name === "string" ? profile.name : "",
display_name:
typeof profile.display_name === "string" ? profile.display_name : "",
picture: typeof profile.picture === "string" ? profile.picture : "",
nip05: typeof profile.nip05 === "string" ? profile.nip05 : "",
};
}
function clonePointerMetadata(metadata) {
if (!metadata || typeof metadata !== "object") {
return null;
}
const cloned = {};
const video = cloneVideoMetadata(metadata.video);
if (video) {
cloned.video = video;
}
const profile = cloneProfileMetadata(metadata.profile);
if (profile) {
cloned.profile = profile;
}
if (Number.isFinite(metadata.resumeAt)) {
cloned.resumeAt = Math.max(0, Math.floor(metadata.resumeAt));
}
if (metadata.completed === true) {
cloned.completed = true;
}
if (Number.isFinite(metadata.watchedAt)) {
cloned.watchedAt = Math.max(0, Math.floor(metadata.watchedAt));
}
return Object.keys(cloned).length ? cloned : null;
}
function mergePointerDetails(target, source) {
if (!target || !source) {
return;
}
if (!target.metadata && source.metadata) {
target.metadata = clonePointerMetadata(source.metadata);
} else if (target.metadata && source.metadata) {
const merged = clonePointerMetadata(target.metadata) || {};
const additional = clonePointerMetadata(source.metadata) || {};
if (!merged.video && additional.video) {
merged.video = additional.video;
}
if (!merged.profile && additional.profile) {
merged.profile = additional.profile;
}
if (!Number.isFinite(merged.resumeAt) && Number.isFinite(additional.resumeAt)) {
merged.resumeAt = additional.resumeAt;
}
if (additional.completed === true) {
merged.completed = true;
}
if (!Number.isFinite(merged.watchedAt) && Number.isFinite(additional.watchedAt)) {
merged.watchedAt = additional.watchedAt;
}
target.metadata = Object.keys(merged).length ? merged : undefined;
}
if (!target.video && source.video) {
target.video = cloneVideoMetadata(source.video);
}
if (!target.profile && source.profile) {
target.profile = cloneProfileMetadata(source.profile);
}
if (!Number.isFinite(target.resumeAt) && Number.isFinite(source.resumeAt)) {
target.resumeAt = Math.max(0, Math.floor(source.resumeAt));
}
if (source.completed === true) {
target.completed = true;
}
}
function clonePointerItem(pointer) {
if (!pointer || typeof pointer !== "object") {
return null;
@@ -1359,6 +1470,31 @@ function clonePointerItem(pointer) {
cloned.session = true;
}
const metadata = clonePointerMetadata(pointer.metadata);
if (metadata) {
cloned.metadata = metadata;
}
const video = cloneVideoMetadata(pointer.video) || metadata?.video || null;
if (video) {
cloned.video = video;
}
const profile = cloneProfileMetadata(pointer.profile) || metadata?.profile || null;
if (profile) {
cloned.profile = profile;
}
if (Number.isFinite(pointer.resumeAt)) {
cloned.resumeAt = Math.max(0, Math.floor(pointer.resumeAt));
} else if (Number.isFinite(metadata?.resumeAt)) {
cloned.resumeAt = metadata.resumeAt;
}
if (pointer.completed === true || metadata?.completed === true) {
cloned.completed = true;
}
return cloned;
}
@@ -2181,7 +2317,21 @@ function canonicalizeWatchHistoryItems(rawItems, maxItems = WATCH_HISTORY_MAX_IT
? pointer.watchedAt
: 0;
if (incomingWatched > currentWatched) {
mergePointerDetails(pointer, existing);
pointer.watchedAt = incomingWatched;
pointer.session = existing.session === true || pointer.session === true;
seen.set(key, pointer);
continue;
}
if (incomingWatched === currentWatched) {
mergePointerDetails(existing, pointer);
} else {
mergePointerDetails(existing, pointer);
}
if (pointer.session === true) {
existing.session = true;
}
}
}
@@ -2230,25 +2380,81 @@ function serializeWatchHistoryItems(items) {
return "[]";
}
const normalized = items.map((item) => {
const type = item?.type === "a" ? "a" : "e";
const value = typeof item?.value === "string" ? item.value : "";
const relay =
typeof item?.relay === "string" && item.relay.trim()
? item.relay.trim()
const normalized = items
.map((item) => {
const type = item?.type === "a" ? "a" : "e";
const value = typeof item?.value === "string" ? item.value : "";
if (!type || !value) {
return null;
}
const relay =
typeof item?.relay === "string" && item.relay.trim()
? item.relay.trim()
: undefined;
const watchedAt = Number.isFinite(item?.watchedAt)
? Math.max(0, Math.floor(item.watchedAt))
: undefined;
const watchedAt = Number.isFinite(item?.watchedAt)
? Math.max(0, Math.floor(item.watchedAt))
: undefined;
const payload = { type, value };
if (relay) {
payload.relay = relay;
}
if (watchedAt !== undefined) {
payload.watchedAt = watchedAt;
}
return payload;
});
const payload = { type, value };
if (relay) {
payload.relay = relay;
}
if (watchedAt !== undefined) {
payload.watchedAt = watchedAt;
}
const metadata = clonePointerMetadata(item?.metadata);
if (metadata) {
payload.metadata = metadata;
}
const video = metadata?.video || cloneVideoMetadata(item?.video);
if (video) {
if (payload.metadata) {
payload.metadata.video = payload.metadata.video || video;
} else {
payload.video = video;
}
}
const profile = metadata?.profile || cloneProfileMetadata(item?.profile);
if (profile) {
if (payload.metadata) {
payload.metadata.profile = payload.metadata.profile || profile;
} else {
payload.profile = profile;
}
}
const resumeAt = Number.isFinite(item?.resumeAt)
? Math.max(0, Math.floor(item.resumeAt))
: undefined;
if (resumeAt !== undefined) {
if (payload.metadata) {
if (!Number.isFinite(payload.metadata.resumeAt)) {
payload.metadata.resumeAt = resumeAt;
}
} else {
payload.resumeAt = resumeAt;
}
}
if (item?.completed === true) {
if (payload.metadata) {
payload.metadata.completed = true;
} else {
payload.completed = true;
}
}
if (payload.metadata && !Object.keys(payload.metadata).length) {
delete payload.metadata;
}
return payload;
})
.filter(Boolean);
return JSON.stringify(normalized);
}
+42 -9
View File
@@ -10,6 +10,35 @@ function normalizeString(value) {
return typeof value === "string" ? value.trim() : "";
}
function sanitizeVideoMetadata(video) {
if (!video || typeof video !== "object") {
return null;
}
const createdAtCandidates = [
video.rootCreatedAt,
video.created_at,
video.createdAt,
video.publishedAt,
];
let createdAt = null;
for (const candidate of createdAtCandidates) {
if (Number.isFinite(candidate)) {
createdAt = Math.floor(candidate);
break;
}
}
return {
id: typeof video.id === "string" ? video.id : "",
title: typeof video.title === "string" ? video.title : "",
thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
created_at: createdAt,
};
}
export default class WatchHistoryTelemetry {
constructor({
watchHistoryService = null,
@@ -165,12 +194,7 @@ export default class WatchHistoryTelemetry {
}
const metadata = {
video: {
id: typeof video.id === "string" ? video.id : "",
title: typeof video.title === "string" ? video.title : "",
thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
},
video: sanitizeVideoMetadata(video),
};
try {
@@ -279,6 +303,7 @@ export default class WatchHistoryTelemetry {
videoElement,
pointer,
pointerKey: explicitPointerKey,
video,
} = {}) {
this.cancelPlaybackLogging();
@@ -305,6 +330,7 @@ export default class WatchHistoryTelemetry {
handlers: [],
viewTimerId: null,
viewFired: false,
videoMetadata: sanitizeVideoMetadata(video),
};
const clearTimer = (timerKey) => {
@@ -367,15 +393,22 @@ export default class WatchHistoryTelemetry {
};
const activeWatchActor = resolveWatchActor();
const watchMetadata = activeWatchActor
? { actor: activeWatchActor }
const watchMetadata = {};
if (activeWatchActor) {
watchMetadata.actor = activeWatchActor;
}
if (state.videoMetadata) {
watchMetadata.video = state.videoMetadata;
}
const metadataPayload = Object.keys(watchMetadata).length
? watchMetadata
: undefined;
if (canUseWatchHistoryService) {
viewResult = await this.watchHistoryService.publishView(
thresholdPointer,
undefined,
watchMetadata,
metadataPayload,
);
} else if (typeof this.nostrClient?.recordVideoView === "function") {
viewResult = await this.nostrClient.recordVideoView(
+79
View File
@@ -260,6 +260,23 @@ function sanitizeVideoForStorage(video) {
};
}
function sanitizeVideoForHistory(video) {
const sanitized = sanitizeVideoForStorage(video);
if (!sanitized) {
return null;
}
return {
id: sanitized.id,
title: sanitized.title,
thumbnail: sanitized.thumbnail,
pubkey: sanitized.pubkey,
created_at: sanitized.created_at,
url: sanitized.url,
magnet: sanitized.magnet,
};
}
function sanitizeProfileForStorage(profile) {
if (!profile || typeof profile !== "object") {
return null;
@@ -357,6 +374,41 @@ function restoreMetadataCache() {
}
}
function sanitizePointerMetadata(metadata) {
if (!metadata || typeof metadata !== "object") {
return null;
}
const pointerMetadata = {};
const video = sanitizeVideoForHistory(metadata.video);
if (video) {
pointerMetadata.video = video;
}
const profile = sanitizeProfileForStorage(metadata.profile);
if (profile) {
pointerMetadata.profile = profile;
}
const resumeCandidates = [
metadata.resumeAt,
metadata.resume,
metadata.resumeSeconds,
];
for (const candidate of resumeCandidates) {
if (Number.isFinite(candidate)) {
pointerMetadata.resumeAt = Math.max(0, Math.floor(candidate));
break;
}
}
if (metadata.completed === true) {
pointerMetadata.completed = true;
}
return Object.keys(pointerMetadata).length ? pointerMetadata : null;
}
function persistMetadataCache() {
const storage = getLocalStorage();
if (!storage) {
@@ -975,6 +1027,33 @@ async function publishView(pointerInput, createdAt, metadata = {}) {
viewResult?.event?.created_at
);
const pointerMetadata = sanitizePointerMetadata(metadata);
if (pointerMetadata) {
const existingMetadata =
typeof normalizedPointer.metadata === "object"
? normalizedPointer.metadata
: {};
normalizedPointer.metadata = { ...existingMetadata, ...pointerMetadata };
if (pointerMetadata.video) {
normalizedPointer.video = pointerMetadata.video;
}
if (pointerMetadata.profile) {
normalizedPointer.profile = pointerMetadata.profile;
}
if (
Number.isFinite(pointerMetadata.resumeAt) &&
!Number.isFinite(normalizedPointer.resumeAt)
) {
normalizedPointer.resumeAt = Math.max(
0,
Math.floor(pointerMetadata.resumeAt),
);
}
if (pointerMetadata.completed === true) {
normalizedPointer.completed = true;
}
}
const key = pointerKey(normalizedPointer);
console.info(
"[watchHistoryService] Watch list process triggered by video view.",
@@ -174,6 +174,58 @@ async function testPreparePlaybackLoggingPublishesOnce() {
);
}
async function testPreparePlaybackLoggingIncludesVideoMetadata() {
const publishCalls = [];
const pointer = { type: "e", value: "event-id" };
const pointerKey = "event-id";
const { telemetry } = createTelemetry({
serviceOverrides: {
publishView: async (ptr, _relay, metadata) => {
publishCalls.push({ ptr, metadata });
return {
ok: true,
event: { pubkey: hexPubkey },
};
},
},
});
const videoElement = new FakeVideoElement();
videoElement.paused = false;
videoElement.currentTime = telemetry.viewThresholdSeconds;
const videoMetadata = {
id: "evt1",
title: "Example Video",
thumbnail: "https://example.com/thumb.jpg",
pubkey: hexPubkey,
rootCreatedAt: 1_700_000_000,
url: "https://example.com/video.mp4",
};
telemetry.preparePlaybackLogging({
videoElement,
pointer,
pointerKey,
video: videoMetadata,
});
await flushAsyncOperations();
assert.equal(publishCalls.length, 1, "publishView should be invoked once");
assert.equal(publishCalls[0].ptr, pointer);
assert.ok(publishCalls[0].metadata, "metadata payload should exist");
assert.deepEqual(publishCalls[0].metadata.video, {
id: "evt1",
title: "Example Video",
thumbnail: "https://example.com/thumb.jpg",
pubkey: hexPubkey,
created_at: 1_700_000_000,
});
}
async function testCancelPlaybackLoggingFlushes() {
let flushArgs = null;
const video = new FakeVideoElement();
@@ -202,6 +254,7 @@ async function testCancelPlaybackLoggingFlushes() {
await testMetadataPreferenceSync();
await testPreparePlaybackLoggingPublishesOnce();
await testPreparePlaybackLoggingIncludesVideoMetadata();
await testCancelPlaybackLoggingFlushes();
console.log("watch-history-telemetry tests passed");