mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
528 lines
15 KiB
JavaScript
528 lines
15 KiB
JavaScript
// js/feedEngine/watchHistoryFeed.js
|
|
|
|
import { pointerKey, normalizePointerInput } from "../nostr.js";
|
|
import watchHistoryService from "../watchHistoryService.js";
|
|
import nostrService from "../services/nostrService.js";
|
|
import { createWatchHistoryMetadataResolver } from "../watchHistoryMetadata.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 createWatchHistoryHydratorStage({
|
|
service = watchHistoryService,
|
|
metadataResolver,
|
|
} = {}) {
|
|
const resolver = metadataResolver || createWatchHistoryMetadataResolver();
|
|
|
|
return async function watchHistoryHydratorStage(items = [], context = {}) {
|
|
const shouldStoreMetadata =
|
|
typeof service?.shouldStoreMetadata === "function"
|
|
? service.shouldStoreMetadata() !== false
|
|
: true;
|
|
|
|
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 pointerType =
|
|
typeof item?.pointer?.type === "string" ? item.pointer.type : null;
|
|
const pointerValue =
|
|
typeof item?.pointer?.value === "string" ? item.pointer.value : null;
|
|
|
|
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;
|
|
|
|
let video = item.video || null;
|
|
let profile = item.metadata?.profile || null;
|
|
|
|
if (isWatchHistoryDebugEnabled()) {
|
|
debugInfo("Hydrating watch history item.", {
|
|
pointerKey: pointerKeyValue || null,
|
|
pointerType,
|
|
pointerValue,
|
|
hasInitialVideo: Boolean(video),
|
|
hasInitialProfile: Boolean(profile),
|
|
watchedAt: watchedAtValue || null,
|
|
});
|
|
}
|
|
|
|
if (pointerKeyValue && typeof service?.getLocalMetadata === "function") {
|
|
try {
|
|
const stored = service.getLocalMetadata(pointerKeyValue);
|
|
if (stored) {
|
|
const hadVideo = Boolean(video);
|
|
const hadProfile = Boolean(profile);
|
|
video = video || stored.video || null;
|
|
profile = profile || stored.profile || null;
|
|
if (isWatchHistoryDebugEnabled()) {
|
|
debugInfo("Loaded cached metadata for pointer.", {
|
|
pointerKey: pointerKeyValue,
|
|
hadVideo,
|
|
hadProfile,
|
|
cachedVideo: Boolean(stored.video),
|
|
cachedProfile: Boolean(stored.profile),
|
|
resultingVideo: Boolean(video),
|
|
resultingProfile: Boolean(profile),
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
context?.log?.(
|
|
"[watch-history-feed] Failed to read cached metadata",
|
|
error,
|
|
);
|
|
if (isWatchHistoryDebugEnabled()) {
|
|
debugWarn("Failed to read cached metadata for pointer.", {
|
|
pointerKey: pointerKeyValue || null,
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!video && resolver?.resolveVideo) {
|
|
if (isWatchHistoryDebugEnabled()) {
|
|
debugInfo("Resolving video via metadata resolver.", {
|
|
pointerKey: pointerKeyValue || null,
|
|
pointerType,
|
|
pointerValue,
|
|
});
|
|
}
|
|
try {
|
|
video = await resolver.resolveVideo(item.pointer);
|
|
if (isWatchHistoryDebugEnabled()) {
|
|
if (video) {
|
|
debugInfo("Metadata resolver returned video candidate.", {
|
|
pointerKey: pointerKeyValue || null,
|
|
videoId: typeof video.id === "string" ? video.id : null,
|
|
title:
|
|
typeof video.title === "string" && video.title.trim()
|
|
? video.title
|
|
: null,
|
|
invalid: Boolean(video.invalid),
|
|
});
|
|
} else {
|
|
debugWarn("Metadata resolver returned no video candidate.", {
|
|
pointerKey: pointerKeyValue || null,
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
context?.log?.(
|
|
"[watch-history-feed] Failed to resolve video from pointer",
|
|
error,
|
|
);
|
|
if (isWatchHistoryDebugEnabled()) {
|
|
debugWarn("Metadata resolver threw while resolving video.", {
|
|
pointerKey: pointerKeyValue || null,
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (video?.pubkey && !profile && resolver?.resolveProfile) {
|
|
if (isWatchHistoryDebugEnabled()) {
|
|
debugInfo("Resolving profile for video author.", {
|
|
pointerKey: pointerKeyValue || null,
|
|
pubkey: video.pubkey,
|
|
});
|
|
}
|
|
try {
|
|
profile = resolver.resolveProfile(video.pubkey) || null;
|
|
if (isWatchHistoryDebugEnabled()) {
|
|
if (profile) {
|
|
const label =
|
|
profile.display_name ||
|
|
profile.name ||
|
|
profile.username ||
|
|
profile.nip05 ||
|
|
null;
|
|
debugInfo("Resolved profile for video author.", {
|
|
pointerKey: pointerKeyValue || null,
|
|
pubkey: video.pubkey,
|
|
label: label || null,
|
|
});
|
|
} else {
|
|
debugWarn("Resolver returned no profile for video author.", {
|
|
pointerKey: pointerKeyValue || null,
|
|
pubkey: video.pubkey,
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
context?.log?.(
|
|
"[watch-history-feed] Failed to resolve profile for pointer",
|
|
error,
|
|
);
|
|
if (isWatchHistoryDebugEnabled()) {
|
|
debugWarn("Profile resolver threw while hydrating metadata.", {
|
|
pointerKey: pointerKeyValue || null,
|
|
pubkey: video.pubkey,
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
video: video || null,
|
|
profile: profile || null,
|
|
};
|
|
|
|
if (isWatchHistoryDebugEnabled()) {
|
|
const summary = {
|
|
pointerKey: pointerKeyValue || null,
|
|
pointerType,
|
|
pointerValue,
|
|
watchedAt: watchedAtValue || null,
|
|
videoId: typeof video?.id === "string" ? video.id : null,
|
|
title:
|
|
typeof video?.title === "string" && video.title?.trim()
|
|
? video.title
|
|
: null,
|
|
invalid: Boolean(video?.invalid),
|
|
hasProfile: Boolean(profile),
|
|
};
|
|
if (video) {
|
|
debugInfo("Hydrated watch history entry metadata.", summary);
|
|
} else {
|
|
debugWarn("Hydrated entry is missing resolved video metadata.", summary);
|
|
}
|
|
}
|
|
|
|
const nextItem = {
|
|
...item,
|
|
pointerKey: pointerKeyValue,
|
|
watchedAt: watchedAtValue,
|
|
video: video || null,
|
|
metadata,
|
|
};
|
|
results.push(nextItem);
|
|
|
|
if (pointerKeyValue) {
|
|
if (shouldStoreMetadata) {
|
|
try {
|
|
service?.setLocalMetadata?.(pointerKeyValue, {
|
|
video: metadata.video,
|
|
profile: metadata.profile,
|
|
});
|
|
if (isWatchHistoryDebugEnabled()) {
|
|
debugInfo("Stored metadata cache entry for pointer.", {
|
|
pointerKey: pointerKeyValue,
|
|
cachedVideo: Boolean(metadata.video),
|
|
cachedProfile: Boolean(metadata.profile),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
context?.log?.(
|
|
"[watch-history-feed] Failed to persist metadata cache",
|
|
error,
|
|
);
|
|
if (isWatchHistoryDebugEnabled()) {
|
|
debugWarn("Failed to persist metadata cache entry.", {
|
|
pointerKey: pointerKeyValue,
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
} else if (typeof service?.removeLocalMetadata === "function") {
|
|
try {
|
|
service.removeLocalMetadata(pointerKeyValue);
|
|
if (isWatchHistoryDebugEnabled()) {
|
|
debugInfo("Cleared metadata cache entry for pointer.", {
|
|
pointerKey: pointerKeyValue,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
context?.log?.(
|
|
"[watch-history-feed] Failed to clear cached metadata",
|
|
error,
|
|
);
|
|
if (isWatchHistoryDebugEnabled()) {
|
|
debugWarn("Failed to clear cached metadata entry.", {
|
|
pointerKey: pointerKeyValue,
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
metadataResolverFactory = createWatchHistoryMetadataResolver,
|
|
shouldIncludeVideo,
|
|
} = {}) {
|
|
const metadataResolver = metadataResolverFactory({ nostr });
|
|
|
|
const blacklistStage = createBlacklistFilterStage({
|
|
shouldIncludeVideo:
|
|
typeof shouldIncludeVideo === "function"
|
|
? shouldIncludeVideo
|
|
: (video, options) =>
|
|
(nostr?.shouldIncludeVideo
|
|
? nostr.shouldIncludeVideo(video, options)
|
|
: true),
|
|
});
|
|
|
|
return {
|
|
source: createWatchHistorySource({ service }),
|
|
stages: [
|
|
createWatchHistoryHydratorStage({ service, metadataResolver }),
|
|
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);
|
|
}
|
|
|