mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 08:22:24 +00:00
This introduces a new stage in the watch history feed pipeline that fetches missing video metadata from relays. Since watch history storage was optimized to store only event IDs, this hydration step ensures that the UI can display titles and thumbnails without requiring them to be persisted in the history payload itself. - Implemented `createWatchHistoryHydrationStage` in `js/feedEngine/watchHistoryFeed.js`. - Integrated `convertEventToVideo` to transform fetched events into usable video objects. - Added a comprehensive integration test in `tests/watch-history.test.mjs` verifying the full flow from ID-only storage to hydrated feed items.
373 lines
9.7 KiB
JavaScript
373 lines
9.7 KiB
JavaScript
// js/feedEngine/watchHistoryFeed.js
|
|
|
|
import { pointerKey, normalizePointerInput } from "../nostr/watchHistory.js";
|
|
import { convertEventToVideo } from "../nostr/index.js";
|
|
import watchHistoryService from "../watchHistoryService.js";
|
|
import nostrService from "../services/nostrService.js";
|
|
import { nostrClient } from "../nostrClientFacade.js";
|
|
import {
|
|
isWatchHistoryDebugEnabled,
|
|
logWatchHistoryDebug,
|
|
} from "../watchHistoryDebug.js";
|
|
import {
|
|
createBlacklistFilterStage,
|
|
createWatchHistorySuppressionStage,
|
|
} from "./stages.js";
|
|
import { isPlainObject } from "./utils.js";
|
|
|
|
const WATCH_HISTORY_FEED_LOG_NAMESPACE = "watchHistoryFeed";
|
|
|
|
function debugInfo(message, details) {
|
|
logWatchHistoryDebug(WATCH_HISTORY_FEED_LOG_NAMESPACE, "info", message, details);
|
|
}
|
|
|
|
function debugWarn(message, details) {
|
|
logWatchHistoryDebug(WATCH_HISTORY_FEED_LOG_NAMESPACE, "warn", message, details);
|
|
}
|
|
|
|
function normalizeActorCandidate(...values) {
|
|
for (const value of values) {
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
if (trimmed) {
|
|
return trimmed;
|
|
}
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function resolveWatchedAt(...candidates) {
|
|
for (const candidate of candidates) {
|
|
if (!Number.isFinite(candidate)) {
|
|
continue;
|
|
}
|
|
let normalized = Math.floor(Number(candidate));
|
|
if (normalized <= 0) {
|
|
continue;
|
|
}
|
|
if (normalized > 10_000_000_000) {
|
|
normalized = Math.floor(normalized / 1000);
|
|
}
|
|
if (normalized > 0) {
|
|
return normalized;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function resolveResumeSeconds(candidate) {
|
|
const candidates = [
|
|
candidate?.resumeSeconds,
|
|
candidate?.resume,
|
|
candidate?.resumeAt,
|
|
candidate?.resumePosition,
|
|
candidate?.resumeTime,
|
|
candidate?.progress?.resume,
|
|
candidate?.progress?.seconds,
|
|
candidate?.progress?.position,
|
|
candidate?.pointer?.resumeSeconds,
|
|
candidate?.pointer?.resume,
|
|
candidate?.pointer?.resumeAt,
|
|
];
|
|
|
|
for (const entry of candidates) {
|
|
if (!Number.isFinite(entry)) {
|
|
continue;
|
|
}
|
|
const normalized = Math.max(0, Math.floor(entry));
|
|
if (normalized > 0) {
|
|
return normalized;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveCompleted(candidate) {
|
|
const candidates = [
|
|
candidate?.completed,
|
|
candidate?.complete,
|
|
candidate?.progress?.completed,
|
|
candidate?.progress?.complete,
|
|
candidate?.pointer?.completed,
|
|
candidate?.pointer?.complete,
|
|
];
|
|
return candidates.some((value) => value === true);
|
|
}
|
|
|
|
function normalizeHistoryEntry(raw) {
|
|
const pointer = normalizePointerInput(raw?.pointer || raw);
|
|
if (!pointer) {
|
|
return null;
|
|
}
|
|
|
|
const key = pointerKey(pointer);
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
|
|
const watchedAt = resolveWatchedAt(
|
|
raw?.watchedAt,
|
|
raw?.timestamp,
|
|
raw?.created_at,
|
|
pointer?.watchedAt,
|
|
raw?.metadata?.watchedAt,
|
|
pointer?.metadata?.watchedAt,
|
|
);
|
|
|
|
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
|
|
? raw.video
|
|
: pointerVideo,
|
|
metadata: {
|
|
source: "watch-history",
|
|
pointerKey: key,
|
|
watchedAt,
|
|
resumeAt,
|
|
completed,
|
|
session: raw?.session === true || pointer.session === true,
|
|
video: pointerVideo,
|
|
profile: pointerProfile,
|
|
},
|
|
};
|
|
}
|
|
|
|
function createWatchHistorySource({ service = watchHistoryService } = {}) {
|
|
return async function watchHistorySource(context = {}) {
|
|
const actor = normalizeActorCandidate(
|
|
context?.runtime?.watchHistory?.actor,
|
|
context?.config?.actor,
|
|
context?.runtime?.actor,
|
|
);
|
|
|
|
if (!service || typeof service.loadLatest !== "function") {
|
|
return [];
|
|
}
|
|
|
|
let items = [];
|
|
try {
|
|
items = await Promise.resolve(service.loadLatest(actor || undefined));
|
|
} catch (error) {
|
|
context?.log?.("[watch-history-feed] Failed to load history", error);
|
|
items = [];
|
|
}
|
|
|
|
const results = [];
|
|
for (const raw of Array.isArray(items) ? items : []) {
|
|
const normalized = normalizeHistoryEntry(raw);
|
|
if (normalized) {
|
|
results.push(normalized);
|
|
}
|
|
}
|
|
|
|
results.sort((a, b) => {
|
|
if (a.watchedAt !== b.watchedAt) {
|
|
return b.watchedAt - a.watchedAt;
|
|
}
|
|
return a.pointerKey.localeCompare(b.pointerKey);
|
|
});
|
|
|
|
return results;
|
|
};
|
|
}
|
|
|
|
function createWatchHistoryHydrationStage() {
|
|
return async function watchHistoryHydrationStage(items = [], context = {}) {
|
|
const missing = [];
|
|
const idMap = new Map();
|
|
|
|
for (const item of items) {
|
|
if (item.video) {
|
|
continue;
|
|
}
|
|
const pointer = item.pointer;
|
|
if (pointer?.type === "e" && pointer.value) {
|
|
missing.push(pointer.value);
|
|
if (!idMap.has(pointer.value)) {
|
|
idMap.set(pointer.value, []);
|
|
}
|
|
idMap.get(pointer.value).push(item);
|
|
}
|
|
}
|
|
|
|
if (!missing.length) {
|
|
return items;
|
|
}
|
|
|
|
const uniqueIds = Array.from(new Set(missing));
|
|
const relays =
|
|
Array.isArray(nostrClient?.relays) && nostrClient.relays.length
|
|
? nostrClient.relays
|
|
: null;
|
|
|
|
if (!relays || !nostrClient?.pool) {
|
|
return items;
|
|
}
|
|
|
|
try {
|
|
const events = await nostrClient.pool.list(relays, [
|
|
{ ids: uniqueIds },
|
|
]);
|
|
|
|
for (const event of events) {
|
|
if (!event || !event.id) {
|
|
continue;
|
|
}
|
|
try {
|
|
const video = convertEventToVideo(event);
|
|
if (video && !video.invalid) {
|
|
const targets = idMap.get(event.id);
|
|
if (targets) {
|
|
for (const target of targets) {
|
|
target.video = video;
|
|
if (target.metadata) {
|
|
target.metadata.video = video;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
debugWarn(`[watchHistoryFeed] Failed to convert event ${event.id}:`, err);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
debugWarn("[watchHistoryFeed] Hydration fetch failed:", error);
|
|
}
|
|
|
|
return items;
|
|
};
|
|
}
|
|
|
|
function createWatchHistoryHydratorStage({ service = watchHistoryService } = {}) {
|
|
return async function watchHistoryHydratorStage(items = [], context = {}) {
|
|
const results = [];
|
|
|
|
for (const item of Array.isArray(items) ? items : []) {
|
|
if (!item || typeof item !== "object") {
|
|
continue;
|
|
}
|
|
|
|
const pointerKeyValue =
|
|
typeof item.pointerKey === "string" && item.pointerKey
|
|
? item.pointerKey
|
|
: pointerKey(item.pointer);
|
|
|
|
const watchedAtValue = Number.isFinite(item?.watchedAt)
|
|
? Math.max(0, Math.floor(Number(item.watchedAt)))
|
|
: Number.isFinite(item?.metadata?.watchedAt)
|
|
? Math.max(0, Math.floor(Number(item.metadata.watchedAt)))
|
|
: 0;
|
|
|
|
const metadata = {
|
|
...(isPlainObject(item.metadata) ? item.metadata : {}),
|
|
pointerKey: pointerKeyValue,
|
|
watchedAt: watchedAtValue || null,
|
|
resumeAt: item.metadata?.resumeAt ?? null,
|
|
completed: item.metadata?.completed ?? false,
|
|
session: item.metadata?.session === true,
|
|
};
|
|
|
|
const nextItem = {
|
|
...item,
|
|
pointerKey: pointerKeyValue,
|
|
watchedAt: watchedAtValue,
|
|
metadata,
|
|
};
|
|
results.push(nextItem);
|
|
}
|
|
|
|
return results;
|
|
};
|
|
}
|
|
|
|
function createWatchHistorySorter() {
|
|
return function watchHistorySorter(items = []) {
|
|
if (!Array.isArray(items)) {
|
|
return [];
|
|
}
|
|
const copy = [...items];
|
|
copy.sort((a, b) => {
|
|
if (a?.watchedAt !== b?.watchedAt) {
|
|
return (b?.watchedAt || 0) - (a?.watchedAt || 0);
|
|
}
|
|
const aKey = typeof a?.pointerKey === "string" ? a.pointerKey : "";
|
|
const bKey = typeof b?.pointerKey === "string" ? b.pointerKey : "";
|
|
return aKey.localeCompare(bKey);
|
|
});
|
|
return copy;
|
|
};
|
|
}
|
|
|
|
export function createWatchHistoryFeedDefinition({
|
|
service = watchHistoryService,
|
|
nostr = nostrService,
|
|
shouldIncludeVideo,
|
|
} = {}) {
|
|
const blacklistStage = createBlacklistFilterStage({
|
|
shouldIncludeVideo:
|
|
typeof shouldIncludeVideo === "function"
|
|
? shouldIncludeVideo
|
|
: (video, options) =>
|
|
(nostr?.shouldIncludeVideo
|
|
? nostr.shouldIncludeVideo(video, options)
|
|
: true),
|
|
});
|
|
|
|
return {
|
|
source: createWatchHistorySource({ service }),
|
|
stages: [
|
|
createWatchHistoryHydratorStage({ service }),
|
|
createWatchHistoryHydrationStage(),
|
|
blacklistStage,
|
|
createWatchHistorySuppressionStage(),
|
|
],
|
|
sorter: createWatchHistorySorter(),
|
|
defaultConfig: {
|
|
actorFilters: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
export function registerWatchHistoryFeed(engine, options = {}) {
|
|
if (!engine || typeof engine.registerFeed !== "function") {
|
|
return null;
|
|
}
|
|
|
|
const definition = createWatchHistoryFeedDefinition(options);
|
|
return engine.registerFeed("watch-history", definition);
|
|
}
|
|
|