mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Preserve video metadata in watch history payloads
This commit is contained in:
1 parent
4e42db2667
commit
507f09dff5
6 files changed
+435
-30
No files matched your search
@@ -3237,6 +3237,7 @@ class Application {
|
||||
videoElement: videoEl,
|
||||
pointer,
|
||||
pointerKey,
|
||||
video: this.currentVideo,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
Reference in new issue
Block a user