mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
474 lines
15 KiB
JavaScript
474 lines
15 KiB
JavaScript
// Inbound NIP-71 ingest: subscribes to video events published by *other* Nostr
|
|
// apps (kinds 21/22/34235/34236), converts them to bitvid video objects, and
|
|
// injects them into the active-video store so they flow through the existing
|
|
// feed pipeline (whitelist, blacklist, NSFW gate, trust/mute/blur, rendering).
|
|
//
|
|
// Design — ingest mirrors the whitelist model: bitvid's whitelist is "authors
|
|
// allowed to publish/render here," so ingest pulls those same authors' NIP-71
|
|
// content from the wider ecosystem. When whitelist mode is ON, the relay
|
|
// subscription is scoped to whitelisted pubkeys (bounded, no firehose); when a
|
|
// deployer disables whitelist mode, ingest opens to all authors (capped).
|
|
//
|
|
// All gating is reused, not re-implemented: injected videos carry
|
|
// source:"nip71-ingest" + isNsfw (mapped from content-warning), and the
|
|
// render-time filter (nostrService.shouldIncludeVideo -> accessControl.canAccess)
|
|
// remains the authority. Gated by FEATURE_NIP71_INGEST so a single admin switch
|
|
// disables it entirely.
|
|
|
|
import { FEATURE_NIP71_INGEST } from "../constants.js";
|
|
import {
|
|
buildVideoFromNip71Event,
|
|
NIP71_KINDS,
|
|
} from "../nostr/nip71IngestAdapter.js";
|
|
import { devLogger, userLogger } from "../utils/logger.js";
|
|
|
|
const FLUSH_DEBOUNCE_MS = 200;
|
|
// Coalesce ingest-driven feed refreshes so a burst of incoming events can't
|
|
// trigger a re-render/re-subscription storm. The injected videos are already in
|
|
// the active store; this only controls how often we *signal* the feed.
|
|
const REFRESH_THROTTLE_MS = 2500;
|
|
const DEFAULT_LIMIT = 200;
|
|
const SUBSCRIPTION_KEY = "nip71-ingest";
|
|
// The admin whitelist hydrates asynchronously (remote fetch, then a second hex
|
|
// rebuild once NostrTools loads) and does not always emit a change we can hook
|
|
// (e.g. loaded-from-cache unchanged). So if the author scope isn't ready at
|
|
// start, poll a bounded number of times until it is, instead of silently never
|
|
// subscribing.
|
|
const OPEN_RETRY_DELAY_MS = 2000;
|
|
const MAX_OPEN_ATTEMPTS = 30; // ~60s of retries
|
|
// Defer the first subscription until the native feed has rendered (signalled by
|
|
// the first videos:updated). Injecting foreign authors hydrates their WoT graph
|
|
// (kind 30000), which during cold-start competes with the initial feed load; a
|
|
// fallback covers the empty-feed case where no videos:updated ever fires.
|
|
const FEED_READY_FALLBACK_MS = 12000;
|
|
// Some relays do not reliably send EOSE. Do not hold the first visible update
|
|
// forever, but give the initial relay burst time to settle before painting it.
|
|
const INITIAL_SYNC_FALLBACK_MS = 5000;
|
|
|
|
export function createNip71IngestService({
|
|
nostrClient,
|
|
nostrService,
|
|
accessControl,
|
|
featureEnabled = FEATURE_NIP71_INGEST,
|
|
logger = devLogger,
|
|
flushDelayMs = FLUSH_DEBOUNCE_MS,
|
|
limit = DEFAULT_LIMIT,
|
|
openRetryDelayMs = OPEN_RETRY_DELAY_MS,
|
|
maxOpenAttempts = MAX_OPEN_ATTEMPTS,
|
|
refreshThrottleMs = REFRESH_THROTTLE_MS,
|
|
feedReadyFallbackMs = FEED_READY_FALLBACK_MS,
|
|
initialSyncFallbackMs = INITIAL_SYNC_FALLBACK_MS,
|
|
} = {}) {
|
|
let subscription = null;
|
|
let buffer = [];
|
|
let flushTimer = null;
|
|
let retryTimer = null;
|
|
let attemptsLeft = maxOpenAttempts;
|
|
let offWhitelistChange = null;
|
|
let started = false;
|
|
let lastRefreshAt = 0;
|
|
let pendingRefreshTimer = null;
|
|
let deferArmed = false;
|
|
let deferTimer = null;
|
|
let offFeedReady = null;
|
|
let initialSyncing = false;
|
|
let initialRefreshPending = false;
|
|
let initialSyncTimer = null;
|
|
|
|
function isAvailable() {
|
|
return (
|
|
featureEnabled === true &&
|
|
!!nostrClient &&
|
|
typeof nostrClient.getSubscriptionManager === "function"
|
|
);
|
|
}
|
|
|
|
// Build the relay filter set. Returns null when there is nothing to ingest
|
|
// (whitelist mode on but no whitelisted authors yet).
|
|
function resolveFilters() {
|
|
const kinds = Array.from(NIP71_KINDS);
|
|
const whitelistOn = accessControl?.whitelistMode?.() === true;
|
|
if (whitelistOn) {
|
|
const authors =
|
|
typeof accessControl?.getWhitelistPubkeys === "function"
|
|
? accessControl.getWhitelistPubkeys().filter(Boolean)
|
|
: [];
|
|
if (!authors.length) {
|
|
return null;
|
|
}
|
|
return [{ kinds, authors, limit }];
|
|
}
|
|
return [{ kinds, limit }];
|
|
}
|
|
|
|
function scheduleFlush() {
|
|
if (flushTimer) {
|
|
return;
|
|
}
|
|
flushTimer = setTimeout(() => {
|
|
flushTimer = null;
|
|
flush();
|
|
}, flushDelayMs);
|
|
}
|
|
|
|
// Inject a converted foreign video into the active store. Native bitvid videos
|
|
// always win for a given root; among ingested videos, newest wins.
|
|
function injectVideo(video) {
|
|
if (!video || video.invalid) {
|
|
return false;
|
|
}
|
|
const activeMap = nostrClient?.activeMap;
|
|
const allEvents = nostrClient?.allEvents;
|
|
if (!(activeMap instanceof Map) || !(allEvents instanceof Map)) {
|
|
return false;
|
|
}
|
|
const key =
|
|
typeof nostrClient.getActiveKey === "function"
|
|
? nostrClient.getActiveKey(video)
|
|
: "";
|
|
if (!key) {
|
|
return false;
|
|
}
|
|
const existing = activeMap.get(key);
|
|
if (existing) {
|
|
const existingForeign = existing.source === "nip71-ingest";
|
|
if (!existingForeign) {
|
|
return false; // native bitvid video wins
|
|
}
|
|
if (Number(existing.created_at) >= Number(video.created_at)) {
|
|
return false; // older or equal ingest
|
|
}
|
|
}
|
|
activeMap.set(key, video);
|
|
allEvents.set(video.id, video);
|
|
return true;
|
|
}
|
|
|
|
function flush() {
|
|
if (!buffer.length) {
|
|
return 0;
|
|
}
|
|
const events = buffer;
|
|
buffer = [];
|
|
|
|
// Collapse to newest-per-root within the batch before injecting.
|
|
const newestByRoot = new Map();
|
|
for (const event of events) {
|
|
const video = buildVideoFromNip71Event(event);
|
|
if (video.invalid || !video.videoRootId) {
|
|
continue;
|
|
}
|
|
const prev = newestByRoot.get(video.videoRootId);
|
|
if (!prev || Number(video.created_at) > Number(prev.created_at)) {
|
|
newestByRoot.set(video.videoRootId, video);
|
|
}
|
|
}
|
|
|
|
const injectedVideos = [];
|
|
for (const video of newestByRoot.values()) {
|
|
if (injectVideo(video)) {
|
|
injectedVideos.push(video);
|
|
}
|
|
}
|
|
const injected = injectedVideos.length;
|
|
|
|
// Also cache into nostrService.videosMap so ingested videos persist and show
|
|
// up in the author-scoped feeds + the optimistic/persisted render paths — not
|
|
// just the live activeMap engine run. Keeps NIP-71 present across navigation.
|
|
if (injected > 0 && typeof nostrService?.cacheVideos === "function") {
|
|
try {
|
|
nostrService.cacheVideos(injectedVideos);
|
|
} catch (error) {
|
|
logger?.warn?.("[nip71Ingest] cacheVideos failed", error);
|
|
}
|
|
}
|
|
|
|
// Visible (not dev-gated) so ingest is observable in any build while we
|
|
// stabilize the feature.
|
|
userLogger.info(
|
|
`[nip71Ingest] flush: ${events.length} event(s) in, ${injected} injected`,
|
|
);
|
|
|
|
if (injected > 0) {
|
|
if (initialSyncing) {
|
|
// Keep native BitVid, NIP-71 videos, and future NIP-71 media types in
|
|
// the store immediately; defer only the expensive full-feed repaint.
|
|
initialRefreshPending = true;
|
|
} else {
|
|
requestRefresh();
|
|
}
|
|
}
|
|
|
|
return injected;
|
|
}
|
|
|
|
// Signal the feed to re-render, throttled so a burst of ingested events can't
|
|
// drive a re-render/re-subscription storm. The injected videos already live in
|
|
// the active store; the feed re-reads getFilteredActiveVideos itself, so this
|
|
// is purely a "refresh now" nudge.
|
|
function requestRefresh() {
|
|
if (pendingRefreshTimer) {
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
const elapsed = now - lastRefreshAt;
|
|
if (elapsed >= refreshThrottleMs) {
|
|
emitRefresh();
|
|
return;
|
|
}
|
|
pendingRefreshTimer = setTimeout(() => {
|
|
pendingRefreshTimer = null;
|
|
emitRefresh();
|
|
}, refreshThrottleMs - elapsed);
|
|
}
|
|
|
|
function emitRefresh() {
|
|
lastRefreshAt = Date.now();
|
|
const performanceHarness = globalThis?.__bitvidPerformance;
|
|
const performanceRef = globalThis?.performance;
|
|
const startedAt = typeof performanceRef?.now === "function" ? performanceRef.now() : 0;
|
|
performanceHarness?.mark?.("nip71-refresh-filter-start");
|
|
let videos = [];
|
|
try {
|
|
videos =
|
|
typeof nostrService?.getFilteredActiveVideos === "function"
|
|
? nostrService.getFilteredActiveVideos()
|
|
: [];
|
|
} catch (error) {
|
|
logger?.warn?.("[nip71Ingest] getFilteredActiveVideos failed", error);
|
|
}
|
|
const filteredAt = typeof performanceRef?.now === "function" ? performanceRef.now() : startedAt;
|
|
performanceHarness?.mark?.("nip71-refresh-filter-end", {
|
|
durationMs: Math.max(0, filteredAt - startedAt),
|
|
videos: videos.length,
|
|
});
|
|
// Confirm ingested videos survive the feed filter (whitelist/NSFW/etc.).
|
|
const foreignCount = Array.isArray(videos)
|
|
? videos.filter((v) => v?.source === "nip71-ingest").length
|
|
: 0;
|
|
userLogger.info(
|
|
`[nip71Ingest] refresh: ${foreignCount} ingested of ${videos.length} in filtered feed`,
|
|
);
|
|
if (typeof nostrService?.emit === "function") {
|
|
nostrService.emit("videos:updated", {
|
|
videos,
|
|
deleted: [],
|
|
reason: "nip71-ingest",
|
|
});
|
|
}
|
|
const emittedAt = typeof performanceRef?.now === "function" ? performanceRef.now() : filteredAt;
|
|
performanceHarness?.mark?.("nip71-refresh-dispatch-end", {
|
|
filterMs: Math.max(0, filteredAt - startedAt),
|
|
dispatchMs: Math.max(0, emittedAt - filteredAt),
|
|
videos: videos.length,
|
|
});
|
|
if (performanceHarness && (filteredAt - startedAt > 50 || emittedAt - filteredAt > 50)) {
|
|
userLogger.info(
|
|
`[nip71Ingest] refresh timing: filter ${Math.round(filteredAt - startedAt)}ms, dispatch ${Math.round(emittedAt - filteredAt)}ms, videos ${videos.length}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function finishInitialSync() {
|
|
if (!initialSyncing) {
|
|
return;
|
|
}
|
|
if (initialSyncTimer) {
|
|
clearTimeout(initialSyncTimer);
|
|
initialSyncTimer = null;
|
|
}
|
|
if (flushTimer) {
|
|
clearTimeout(flushTimer);
|
|
flushTimer = null;
|
|
flush();
|
|
}
|
|
initialSyncing = false;
|
|
if (initialRefreshPending) {
|
|
initialRefreshPending = false;
|
|
requestRefresh();
|
|
}
|
|
}
|
|
|
|
// Defer the first start until the native feed has rendered (first
|
|
// videos:updated), with a fallback timer for the empty-feed case. Keeps ingest
|
|
// off the cold-start critical path.
|
|
function startWhenFeedReady() {
|
|
if (started || deferArmed || !isAvailable()) {
|
|
return false;
|
|
}
|
|
deferArmed = true;
|
|
|
|
const begin = () => {
|
|
if (typeof offFeedReady === "function") {
|
|
offFeedReady();
|
|
offFeedReady = null;
|
|
}
|
|
if (deferTimer) {
|
|
clearTimeout(deferTimer);
|
|
deferTimer = null;
|
|
}
|
|
deferArmed = false;
|
|
start({ coalesceInitialSync: true });
|
|
};
|
|
|
|
if (typeof nostrService?.on === "function") {
|
|
offFeedReady = nostrService.on("videos:updated", begin);
|
|
}
|
|
deferTimer = setTimeout(begin, feedReadyFallbackMs);
|
|
return true;
|
|
}
|
|
|
|
function start({ coalesceInitialSync = false } = {}) {
|
|
if (started || !isAvailable()) {
|
|
return false;
|
|
}
|
|
started = true;
|
|
attemptsLeft = maxOpenAttempts;
|
|
initialSyncing = coalesceInitialSync;
|
|
initialRefreshPending = false;
|
|
if (initialSyncing) {
|
|
initialSyncTimer = setTimeout(finishInitialSync, initialSyncFallbackMs);
|
|
}
|
|
|
|
// Re-subscribe when the whitelist changes so the author scope stays current.
|
|
if (typeof accessControl?.onWhitelistChange === "function") {
|
|
offWhitelistChange = accessControl.onWhitelistChange(() => {
|
|
restart();
|
|
});
|
|
}
|
|
|
|
return attemptOpen();
|
|
}
|
|
|
|
// Open the subscription; if the author scope isn't ready yet (whitelist still
|
|
// hydrating), retry on a timer rather than giving up silently.
|
|
function attemptOpen() {
|
|
if (retryTimer) {
|
|
clearTimeout(retryTimer);
|
|
retryTimer = null;
|
|
}
|
|
if (subscription) {
|
|
return true;
|
|
}
|
|
const opened = openSubscription();
|
|
if (opened) {
|
|
return true;
|
|
}
|
|
if (started && attemptsLeft > 0) {
|
|
attemptsLeft -= 1;
|
|
retryTimer = setTimeout(attemptOpen, openRetryDelayMs);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function openSubscription() {
|
|
const filters = resolveFilters();
|
|
if (!filters) {
|
|
return false; // nothing to ingest yet (e.g. whitelist not hydrated) — retry
|
|
}
|
|
try {
|
|
const manager = nostrClient.getSubscriptionManager();
|
|
subscription = manager.subscribe({
|
|
key: SUBSCRIPTION_KEY,
|
|
filters,
|
|
label: "nip71-ingest",
|
|
onEvent: (event) => {
|
|
if (event && NIP71_KINDS.has(Number(event.kind))) {
|
|
buffer.push(event);
|
|
scheduleFlush();
|
|
}
|
|
},
|
|
onEose: finishInitialSync,
|
|
});
|
|
const authorCount = Array.isArray(filters[0]?.authors)
|
|
? filters[0].authors.length
|
|
: "all";
|
|
userLogger.info(
|
|
`[nip71Ingest] subscribed (authors: ${authorCount}, kinds: ${filters[0]?.kinds?.join(",")})`,
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
logger?.warn?.("[nip71Ingest] subscribe failed", error);
|
|
subscription = null;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function closeSubscription() {
|
|
if (flushTimer) {
|
|
clearTimeout(flushTimer);
|
|
flushTimer = null;
|
|
}
|
|
if (retryTimer) {
|
|
clearTimeout(retryTimer);
|
|
retryTimer = null;
|
|
}
|
|
if (pendingRefreshTimer) {
|
|
clearTimeout(pendingRefreshTimer);
|
|
pendingRefreshTimer = null;
|
|
}
|
|
if (initialSyncTimer) {
|
|
clearTimeout(initialSyncTimer);
|
|
initialSyncTimer = null;
|
|
}
|
|
initialSyncing = false;
|
|
initialRefreshPending = false;
|
|
buffer = [];
|
|
if (subscription && typeof subscription.close === "function") {
|
|
try {
|
|
subscription.close();
|
|
} catch (error) {
|
|
logger?.warn?.("[nip71Ingest] close failed", error);
|
|
}
|
|
}
|
|
subscription = null;
|
|
}
|
|
|
|
function restart() {
|
|
if (!started) {
|
|
return false;
|
|
}
|
|
closeSubscription();
|
|
attemptsLeft = maxOpenAttempts;
|
|
return attemptOpen();
|
|
}
|
|
|
|
function stop() {
|
|
closeSubscription();
|
|
if (typeof offFeedReady === "function") {
|
|
try {
|
|
offFeedReady();
|
|
} catch (error) {
|
|
// best-effort
|
|
}
|
|
offFeedReady = null;
|
|
}
|
|
if (deferTimer) {
|
|
clearTimeout(deferTimer);
|
|
deferTimer = null;
|
|
}
|
|
deferArmed = false;
|
|
if (typeof offWhitelistChange === "function") {
|
|
try {
|
|
offWhitelistChange();
|
|
} catch (error) {
|
|
// best-effort
|
|
}
|
|
offWhitelistChange = null;
|
|
}
|
|
started = false;
|
|
}
|
|
|
|
return {
|
|
isAvailable,
|
|
startWhenFeedReady,
|
|
start,
|
|
stop,
|
|
restart,
|
|
// Exposed for tests: deterministic flush + single-video injection.
|
|
flush,
|
|
injectVideo,
|
|
resolveFilters,
|
|
};
|
|
}
|