Files
bitvid/js/feedEngine/stages.js
T

364 lines
11 KiB
JavaScript

// js/feedEngine/stages.js
import { getApplication } from "../applicationContext.js";
import nostrService from "../services/nostrService.js";
import moderationService from "../services/moderationService.js";
import { dedupeToNewestByRoot } from "../utils/videoDeduper.js";
import { isPlainObject, toSet } from "./utils.js";
function resolveDedupeFunction(customDedupe) {
if (typeof customDedupe === "function") {
return customDedupe;
}
const app = getApplication?.();
if (app && typeof app.dedupeVideosByRoot === "function") {
return (videos) => app.dedupeVideosByRoot(videos);
}
return (videos) => dedupeToNewestByRoot(videos);
}
export function createDedupeByRootStage({
stageName = "dedupe-by-root",
dedupe: customDedupe,
} = {}) {
const dedupeFn = resolveDedupeFunction(customDedupe);
return async function dedupeByRootStage(items = [], context = {}) {
const videos = items.map((item) => item?.video).filter(Boolean);
const dedupedVideos = dedupeFn(videos) || [];
const allowedIds = new Set();
for (const video of dedupedVideos) {
if (video && typeof video.id === "string") {
allowedIds.add(video.id);
}
}
if (!allowedIds.size) {
return items;
}
const keep = [];
for (const item of items) {
const video = item?.video;
const videoId = video && typeof video.id === "string" ? video.id : "";
if (!videoId || allowedIds.has(videoId)) {
keep.push(item);
continue;
}
const rootId =
typeof video?.videoRootId === "string" && video.videoRootId
? video.videoRootId
: videoId;
if (typeof item?.metadata === "object" && item.metadata) {
item.metadata.droppedByStage = stageName;
}
context?.addWhy?.({
stage: stageName,
type: "dedupe",
reason: "older-root-version",
videoId,
rootId,
});
}
return keep;
};
}
export function createBlacklistFilterStage({
stageName = "blacklist-filter",
shouldIncludeVideo,
} = {}) {
const includeFn =
typeof shouldIncludeVideo === "function"
? shouldIncludeVideo
: (video, options) => nostrService.shouldIncludeVideo(video, options);
return async function blacklistFilterStage(items = [], context = {}) {
const blacklist = toSet(context?.runtime?.blacklistedEventIds);
const isAuthorBlocked =
typeof context?.runtime?.isAuthorBlocked === "function"
? context.runtime.isAuthorBlocked
: () => false;
const options = { blacklistedEventIds: blacklist, isAuthorBlocked };
const results = [];
for (const item of items) {
const video = item?.video;
if (!video || typeof video !== "object") {
results.push(item);
continue;
}
let include = false;
try {
include = includeFn(video, options) !== false;
} catch (error) {
context?.log?.(`[${stageName}] shouldIncludeVideo threw`, error);
include = false;
}
if (!include) {
context?.addWhy?.({
stage: stageName,
type: "filter",
reason: "blacklist",
videoId: typeof video.id === "string" ? video.id : null,
pubkey: typeof video.pubkey === "string" ? video.pubkey : null,
});
continue;
}
results.push(item);
}
return results;
};
}
function resolveSuppressionHook(context) {
if (!context || typeof context !== "object") {
return null;
}
const fromHooks = context?.hooks?.watchHistory;
if (isPlainObject(fromHooks) && typeof fromHooks.shouldSuppress === "function") {
return fromHooks.shouldSuppress;
}
const runtime = context?.runtime?.watchHistory;
if (isPlainObject(runtime) && typeof runtime.shouldSuppress === "function") {
return runtime.shouldSuppress;
}
return null;
}
export function createWatchHistorySuppressionStage({
stageName = "watch-history-suppression",
shouldSuppress,
} = {}) {
return async function watchHistorySuppressionStage(items = [], context = {}) {
const hook =
typeof shouldSuppress === "function" ? shouldSuppress : resolveSuppressionHook(context);
if (typeof hook !== "function") {
return items;
}
const results = [];
for (const item of items) {
let suppress = false;
try {
suppress = await hook(item, context);
} catch (error) {
context?.log?.(`[${stageName}] suppression hook threw`, error);
suppress = false;
}
if (suppress) {
const detail = {
stage: stageName,
type: "filter",
reason: "watch-history",
};
if (item?.video && typeof item.video.id === "string") {
detail.videoId = item.video.id;
}
if (item?.pointer) {
detail.pointer = item.pointer;
} else if (item?.metadata && item.metadata.pointerKey) {
detail.pointerKey = item.metadata.pointerKey;
}
context?.addWhy?.(detail);
continue;
}
results.push(item);
}
return results;
};
}
export function createModerationStage({
stageName = "moderation",
autoplayThreshold = 2,
blurThreshold = 3,
reportType = "nudity",
service = null,
getService,
} = {}) {
const normalizedAutoplayThreshold = Number.isFinite(autoplayThreshold)
? Math.max(0, Math.floor(autoplayThreshold))
: 2;
const normalizedBlurThreshold = Number.isFinite(blurThreshold)
? Math.max(0, Math.floor(blurThreshold))
: 3;
const normalizedReportType = typeof reportType === "string" ? reportType.trim().toLowerCase() : "nudity";
return async function moderationStage(items = [], context = {}) {
let resolvedService = null;
try {
resolvedService =
typeof getService === "function"
? getService({ context })
: service || nostrService.getModerationService?.() || moderationService;
} catch (error) {
context?.log?.(`[${stageName}] Failed to resolve moderation service`, error);
resolvedService = service || moderationService;
}
if (!resolvedService) {
return items;
}
try {
if (typeof resolvedService.refreshViewerFromClient === "function") {
await resolvedService.refreshViewerFromClient();
}
} catch (error) {
context?.log?.(`[${stageName}] Failed to refresh viewer context`, error);
}
const activeIds = new Set();
for (const item of items) {
const videoId = typeof item?.video?.id === "string" ? item.video.id : "";
if (videoId) {
activeIds.add(videoId);
}
}
try {
if (typeof resolvedService.setActiveEventIds === "function") {
await resolvedService.setActiveEventIds(activeIds);
}
} catch (error) {
context?.log?.(`[${stageName}] Failed to prime report subscriptions`, error);
}
const results = [];
for (const item of items) {
if (!item || typeof item !== "object") {
results.push(item);
continue;
}
const video = item.video;
if (!video || typeof video !== "object") {
results.push(item);
continue;
}
const videoId = typeof video.id === "string" ? video.id : "";
let summary = null;
let trustedCount = 0;
let trustedReporters = [];
if (videoId) {
try {
if (typeof resolvedService.getTrustedReportSummary === "function") {
summary = resolvedService.getTrustedReportSummary(videoId);
}
if (typeof resolvedService.trustedReportCount === "function") {
trustedCount = resolvedService.trustedReportCount(videoId, normalizedReportType) || 0;
}
if (typeof resolvedService.getTrustedReporters === "function") {
const reporterEntries = resolvedService.getTrustedReporters(
videoId,
normalizedReportType
);
if (Array.isArray(reporterEntries) && reporterEntries.length) {
trustedReporters = reporterEntries
.map((entry) => {
if (!entry || typeof entry !== "object") {
return null;
}
const pubkey = typeof entry.pubkey === "string" ? entry.pubkey.trim() : "";
if (!pubkey) {
return null;
}
const latest = Number.isFinite(entry.latest)
? Math.floor(entry.latest)
: 0;
return { pubkey, latest };
})
.filter(Boolean);
}
}
} catch (error) {
context?.log?.(`[${stageName}] Failed to fetch moderation summary`, error);
summary = null;
trustedCount = 0;
trustedReporters = [];
}
}
const blockAutoplay = trustedCount >= normalizedAutoplayThreshold;
const blurThumbnail = trustedCount >= normalizedBlurThreshold;
if (!item.metadata || typeof item.metadata !== "object") {
item.metadata = {};
}
const metadataModeration =
item.metadata.moderation && typeof item.metadata.moderation === "object"
? { ...item.metadata.moderation }
: {};
metadataModeration.blockAutoplay = blockAutoplay;
metadataModeration.blurThumbnail = blurThumbnail;
metadataModeration.summary = summary;
metadataModeration.trustedCount = trustedCount;
metadataModeration.reportType = normalizedReportType;
metadataModeration.trustedReporters = Array.isArray(trustedReporters)
? trustedReporters.slice()
: [];
item.metadata.moderation = metadataModeration;
if (!video.moderation || typeof video.moderation !== "object") {
video.moderation = {};
}
video.moderation.blockAutoplay = blockAutoplay;
video.moderation.blurThumbnail = blurThumbnail;
video.moderation.trustedCount = trustedCount;
video.moderation.reportType = normalizedReportType;
if (Array.isArray(trustedReporters) && trustedReporters.length) {
video.moderation.trustedReporters = trustedReporters.slice();
} else if (video.moderation.trustedReporters) {
delete video.moderation.trustedReporters;
}
if (summary) {
video.moderation.summary = summary;
} else if (video.moderation.summary) {
delete video.moderation.summary;
}
if (blockAutoplay || blurThumbnail) {
context?.addWhy?.({
stage: stageName,
type: "moderation",
reason: blurThumbnail ? "blur" : "autoplay-block",
videoId: videoId || null,
reportType: normalizedReportType,
trustedCount,
});
}
results.push(item);
}
return results;
};
}