Preserve playback metadata in watch history

This commit is contained in:
thePR0M3TH3AN
2025-10-09 19:23:29 -04:00
parent b5ee063260
commit 4d367109d5
5 changed files with 302 additions and 3 deletions

View File

@@ -338,7 +338,7 @@ function getPointerVideoId(video, pointer) {
return "";
}
function buildHistoryCard({
export function buildHistoryCard({
item,
video,
profile,

View File

@@ -1374,6 +1374,9 @@ function cloneVideoMetadata(video) {
created_at: createdAt,
url: typeof video.url === "string" ? video.url : "",
magnet: typeof video.magnet === "string" ? video.magnet : "",
infoHash: typeof video.infoHash === "string" ? video.infoHash : "",
legacyInfoHash:
typeof video.legacyInfoHash === "string" ? video.legacyInfoHash : "",
};
}

View File

@@ -36,6 +36,11 @@ function sanitizeVideoMetadata(video) {
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 : "",
infoHash: typeof video.infoHash === "string" ? video.infoHash : "",
legacyInfoHash:
typeof video.legacyInfoHash === "string" ? video.legacyInfoHash : "",
};
}

View File

@@ -253,6 +253,8 @@ function sanitizeVideoForStorage(video) {
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
created_at: createdAt,
infoHash: typeof video.infoHash === "string" ? video.infoHash : "",
legacyInfoHash:
typeof video.legacyInfoHash === "string" ? video.legacyInfoHash : "",
mode: typeof video.mode === "string" ? video.mode : "",
isPrivate: video?.isPrivate === true,
description:
@@ -274,6 +276,13 @@ function sanitizeVideoForHistory(video) {
created_at: sanitized.created_at,
url: sanitized.url,
magnet: sanitized.magnet,
infoHash: sanitized.infoHash,
legacyInfoHash:
typeof video?.legacyInfoHash === "string"
? video.legacyInfoHash
: typeof sanitized.legacyInfoHash === "string"
? sanitized.legacyInfoHash
: "",
};
}

View File

@@ -23,6 +23,7 @@ const {
normalizeActorKey,
} = await import("../js/nostr.js");
const { watchHistoryService } = await import("../js/watchHistoryService.js");
const { buildHistoryCard } = await import("../js/historyView.js");
const { getApplication, setApplication } = await import(
"../js/applicationContext.js"
);
@@ -218,6 +219,43 @@ function createFakeSimplePool() {
const poolHarness = createFakeSimplePool();
nostrClient.pool = poolHarness;
function extractVideoMetadataFromItem(item) {
if (!item || typeof item !== "object") {
return null;
}
const directVideo = item.video;
if (directVideo && typeof directVideo === "object") {
return directVideo;
}
const metadataVideo =
item.metadata && typeof item.metadata === "object"
? item.metadata.video
: null;
if (metadataVideo && typeof metadataVideo === "object") {
return metadataVideo;
}
const pointer = item.pointer && typeof item.pointer === "object"
? item.pointer
: null;
if (pointer) {
if (pointer.video && typeof pointer.video === "object") {
return pointer.video;
}
const pointerMetadata =
pointer.metadata && typeof pointer.metadata === "object"
? pointer.metadata.video
: null;
if (pointerMetadata && typeof pointerMetadata === "object") {
return pointerMetadata;
}
}
return null;
}
nostrClient.relays = ["wss://relay.test"];
nostrClient.readRelays = ["wss://relay.test"];
nostrClient.writeRelays = ["wss://relay.test"];
@@ -1252,6 +1290,15 @@ async function testWatchHistoryServiceIntegration() {
poolHarness.setResolver(() => ({ ok: true }));
const actor = "service-actor";
const pointerVideo = {
id: "video-one",
title: "Video One",
url: "https://cdn.example/video-one.mp4",
magnet:
"magnet:?xt=urn:btih:89abcdef0123456789abcdef0123456789abcdef",
infoHash: "89abcdef0123456789abcdef0123456789abcdef",
legacyInfoHash: "89abcdef0123456789abcdef0123456789abcdef",
};
const restoreCrypto = installSessionCrypto({ privateKey: "service-priv" });
const originalEnsure = nostrClient.ensureSessionActor;
const originalSession = nostrClient.sessionActor;
@@ -1290,13 +1337,13 @@ async function testWatchHistoryServiceIntegration() {
await watchHistoryService.publishView(
{ type: "e", value: "video-one" },
viewCreatedAt,
{ actor },
{ actor, video: pointerVideo },
);
viewCreatedAt += 60;
await watchHistoryService.publishView(
{ type: "e", value: "video-one" },
viewCreatedAt,
{ actor },
{ actor, video: pointerVideo },
);
viewCreatedAt += 30;
await watchHistoryService.publishView(
@@ -1313,6 +1360,36 @@ async function testWatchHistoryServiceIntegration() {
reason: "integration",
});
assert.ok(snapshotResult.ok, "snapshot should publish queued pointers");
const snapshotItems = Array.isArray(snapshotResult.items)
? snapshotResult.items
: [];
const snapshotVideo = extractVideoMetadataFromItem(
snapshotItems.find(
(entry) =>
(entry?.value || entry?.pointer?.value || "") === "video-one",
),
);
assert(snapshotVideo, "snapshot should retain pointer video metadata");
assert.equal(
snapshotVideo?.url,
pointerVideo.url,
"snapshot pointer video should preserve url",
);
assert.equal(
snapshotVideo?.magnet,
pointerVideo.magnet,
"snapshot pointer video should preserve magnet",
);
assert.equal(
snapshotVideo?.infoHash,
pointerVideo.infoHash,
"snapshot pointer video should preserve infoHash",
);
assert.equal(
snapshotVideo?.legacyInfoHash,
pointerVideo.legacyInfoHash,
"snapshot pointer video should preserve legacy info hash",
);
assert.equal(
watchHistoryService.getQueuedPointers(actor).length,
0,
@@ -1325,6 +1402,33 @@ async function testWatchHistoryServiceIntegration() {
snapshotResult.items,
"loadLatest should return decrypted canonical pointers",
);
const resolvedVideo = extractVideoMetadataFromItem(
resolvedItems.find(
(entry) =>
(entry?.value || entry?.pointer?.value || "") === "video-one",
),
);
assert(resolvedVideo, "decrypted history should include pointer video");
assert.equal(
resolvedVideo?.url,
pointerVideo.url,
"decrypted pointer video should expose url",
);
assert.equal(
resolvedVideo?.magnet,
pointerVideo.magnet,
"decrypted pointer video should expose magnet",
);
assert.equal(
resolvedVideo?.infoHash,
pointerVideo.infoHash,
"decrypted pointer video should expose infoHash",
);
assert.equal(
resolvedVideo?.legacyInfoHash,
pointerVideo.legacyInfoHash,
"decrypted pointer video should expose legacy info hash",
);
assert(resolvedItems[0].watchedAt >= resolvedItems[1].watchedAt);
assert.equal(
resolvedItems[0].session,
@@ -1359,6 +1463,183 @@ async function testWatchHistoryServiceIntegration() {
}
}
async function testHistoryCardsUseDecryptedPlaybackMetadata() {
console.log("Running watch history card playback metadata test...");
const pointerVideo = {
id: "history-card",
title: "History Card Video",
url: "https://cdn.example/history-card.mp4",
magnet:
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
infoHash: "0123456789abcdef0123456789abcdef01234567",
legacyInfoHash: "0123456789abcdef0123456789abcdef01234567",
};
const item = {
pointerKey: "e:history-card",
pointer: { type: "e", value: "history-card" },
watchedAt: 1_700_000_500,
};
const originalDocument = globalThis.document;
const originalHTMLElement = globalThis.HTMLElement;
class FakeClassList {
constructor(element) {
this.element = element;
}
_sync() {
this.element._className = Array.from(this.element._classSet).join(" ");
}
add(...tokens) {
tokens.forEach((token) => {
if (token) {
this.element._classSet.add(token);
}
});
this._sync();
}
remove(...tokens) {
tokens.forEach((token) => this.element._classSet.delete(token));
this._sync();
}
toggle(token, force) {
if (!token) {
return false;
}
if (force === true) {
this.element._classSet.add(token);
this._sync();
return true;
}
if (force === false) {
this.element._classSet.delete(token);
this._sync();
return false;
}
if (this.element._classSet.has(token)) {
this.element._classSet.delete(token);
this._sync();
return false;
}
this.element._classSet.add(token);
this._sync();
return true;
}
contains(token) {
return this.element._classSet.has(token);
}
}
class FakeElement {
constructor(tagName) {
this.tagName = typeof tagName === "string" ? tagName.toUpperCase() : "";
this.children = [];
this.parentNode = null;
this.dataset = {};
this.attributes = {};
this.textContent = "";
this._classSet = new Set();
this._className = "";
this.classList = new FakeClassList(this);
}
appendChild(child) {
if (child && typeof child === "object") {
child.parentNode = this;
}
this.children.push(child);
return child;
}
get className() {
return this._className;
}
set className(value) {
const tokens =
typeof value === "string"
? value
.split(/\s+/)
.map((token) => token.trim())
.filter(Boolean)
: [];
this._classSet = new Set(tokens);
this._className = tokens.join(" ");
}
setAttribute(name, value) {
this.attributes[name] = String(value);
}
removeAttribute(name) {
delete this.attributes[name];
}
}
function collectElements(root, predicate, results = []) {
if (!(root instanceof FakeElement)) {
return results;
}
if (predicate(root)) {
results.push(root);
}
for (const child of root.children) {
collectElements(child, predicate, results);
}
return results;
}
const fakeDocument = {
createElement(tagName) {
return new FakeElement(tagName);
},
};
globalThis.document = fakeDocument;
globalThis.HTMLElement = FakeElement;
try {
const card = buildHistoryCard({
item,
video: pointerVideo,
profile: null,
metadataPreference: "encrypted-only",
});
assert(card instanceof FakeElement);
assert.equal(card.dataset.pointerKey, item.pointerKey);
const playLinks = collectElements(
card,
(element) =>
element.tagName === "A" &&
element.dataset.historyAction === "play",
);
assert(playLinks.length >= 1, "card should expose play actions");
assert.equal(
playLinks[0].dataset.playUrl,
encodeURIComponent(pointerVideo.url),
"play action should encode url from metadata",
);
assert.equal(
playLinks[0].dataset.playMagnet,
pointerVideo.magnet,
"play action should surface magnet from metadata",
);
} finally {
globalThis.document = originalDocument;
globalThis.HTMLElement = originalHTMLElement;
}
}
async function testWatchHistoryStaleCacheRefresh() {
console.log("Running watch history stale cache refresh test...");
@@ -1696,6 +1977,7 @@ await testPublishSnapshotFailureRetry();
await testWatchHistoryPartialRelayRetry();
await testResolveWatchHistoryBatchingWindow();
await testWatchHistoryServiceIntegration();
await testHistoryCardsUseDecryptedPlaybackMetadata();
await testWatchHistoryStaleCacheRefresh();
await testWatchHistoryLocalFallbackWhenDisabled();
await testWatchHistorySyncEnabledForLoggedInUsers();