mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
716 lines
18 KiB
JavaScript
716 lines
18 KiB
JavaScript
import {
|
|
nostrClient,
|
|
convertEventToVideo,
|
|
} from "../nostr.js";
|
|
import { accessControl } from "../accessControl.js";
|
|
import { ALLOW_NSFW_CONTENT } from "../config.js";
|
|
import { userLogger } from "../utils/logger.js";
|
|
import moderationService from "./moderationService.js";
|
|
import {
|
|
getVideosMap as getStoredVideosMap,
|
|
setVideosMap as setStoredVideosMap,
|
|
getVideoSubscription as getStoredVideoSubscription,
|
|
setVideoSubscription as setStoredVideoSubscription,
|
|
} from "../state/appState.js";
|
|
|
|
const VIDEO_KIND = 30078;
|
|
|
|
class SimpleEventEmitter {
|
|
constructor(logger = null) {
|
|
this.logger = typeof logger === "function" ? logger : null;
|
|
this.listeners = new Map();
|
|
}
|
|
|
|
on(eventName, handler) {
|
|
if (typeof handler !== "function") {
|
|
return () => {};
|
|
}
|
|
|
|
if (!this.listeners.has(eventName)) {
|
|
this.listeners.set(eventName, new Set());
|
|
}
|
|
|
|
const handlers = this.listeners.get(eventName);
|
|
handlers.add(handler);
|
|
|
|
return () => {
|
|
handlers.delete(handler);
|
|
if (!handlers.size) {
|
|
this.listeners.delete(eventName);
|
|
}
|
|
};
|
|
}
|
|
|
|
emit(eventName, detail) {
|
|
const handlers = this.listeners.get(eventName);
|
|
if (!handlers || !handlers.size) {
|
|
return;
|
|
}
|
|
|
|
for (const handler of Array.from(handlers)) {
|
|
try {
|
|
handler(detail);
|
|
} catch (error) {
|
|
if (this.logger) {
|
|
try {
|
|
this.logger(`nostrService listener for "${eventName}" threw`, error);
|
|
} catch (logError) {
|
|
userLogger.warn("[nostrService] listener logger threw", logError);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function normalizeLogger(logger) {
|
|
if (typeof logger === "function") {
|
|
return logger;
|
|
}
|
|
if (logger && typeof logger.log === "function") {
|
|
return (...args) => logger.log(...args);
|
|
}
|
|
return () => {};
|
|
}
|
|
|
|
function ensureSet(value) {
|
|
if (value instanceof Set) {
|
|
return value;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return new Set(value);
|
|
}
|
|
return new Set();
|
|
}
|
|
|
|
function normalizeHexPubkey(pubkey) {
|
|
if (typeof pubkey !== "string") {
|
|
return "";
|
|
}
|
|
|
|
const trimmed = pubkey.trim();
|
|
return trimmed ? trimmed.toLowerCase() : "";
|
|
}
|
|
|
|
function normalizeUntil(timestamp) {
|
|
const value = Number(timestamp);
|
|
if (!Number.isFinite(value) || value <= 0) {
|
|
return 0;
|
|
}
|
|
const seconds = Math.floor(value);
|
|
return seconds > 0 ? seconds : 0;
|
|
}
|
|
|
|
export class NostrService {
|
|
constructor({ logger } = {}) {
|
|
this.nostrClient = nostrClient;
|
|
this.accessControl = accessControl;
|
|
this.logger = normalizeLogger(logger);
|
|
this.emitter = new SimpleEventEmitter((message, error) => {
|
|
try {
|
|
this.logger(message, error);
|
|
} catch (logError) {
|
|
userLogger.warn("[nostrService] logger threw", logError);
|
|
}
|
|
});
|
|
this.videosMap = null;
|
|
this.videosByAuthorIndex = null;
|
|
this.authorIndexDirty = false;
|
|
this.moderationService = moderationService || null;
|
|
this.initialLoadPromise = null;
|
|
this.initialLoadResolved = false;
|
|
this.initialLoadResolve = null;
|
|
try {
|
|
if (this.moderationService && typeof this.moderationService.setNostrClient === "function") {
|
|
this.moderationService.setNostrClient(this.nostrClient);
|
|
}
|
|
} catch (error) {
|
|
userLogger.warn("[nostrService] Failed to attach moderation service", error);
|
|
}
|
|
}
|
|
|
|
log(...args) {
|
|
try {
|
|
this.logger(...args);
|
|
} catch (error) {
|
|
userLogger.warn("[nostrService] logger threw", error);
|
|
}
|
|
}
|
|
|
|
on(eventName, handler) {
|
|
return this.emitter.on(eventName, handler);
|
|
}
|
|
|
|
emit(eventName, detail) {
|
|
this.emitter.emit(eventName, detail);
|
|
}
|
|
|
|
ensureInitialLoadDeferred() {
|
|
if (!this.initialLoadPromise) {
|
|
this.initialLoadResolved = false;
|
|
this.initialLoadPromise = new Promise((resolve) => {
|
|
this.initialLoadResolve = (value) => {
|
|
if (this.initialLoadResolved) {
|
|
return;
|
|
}
|
|
this.initialLoadResolved = true;
|
|
this.initialLoadResolve = null;
|
|
resolve(value);
|
|
};
|
|
});
|
|
}
|
|
|
|
return this.initialLoadPromise;
|
|
}
|
|
|
|
resolveInitialLoad(value) {
|
|
if (this.initialLoadResolved) {
|
|
return;
|
|
}
|
|
|
|
if (typeof this.initialLoadResolve === "function") {
|
|
try {
|
|
this.initialLoadResolve(value);
|
|
} finally {
|
|
this.initialLoadResolve = null;
|
|
this.initialLoadResolved = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
awaitInitialLoad() {
|
|
return this.ensureInitialLoadDeferred();
|
|
}
|
|
|
|
getModerationService() {
|
|
if (!this.moderationService) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
if (
|
|
typeof this.moderationService.setNostrClient === "function" &&
|
|
this.moderationService.nostrClient !== this.nostrClient
|
|
) {
|
|
this.moderationService.setNostrClient(this.nostrClient);
|
|
}
|
|
|
|
if (typeof this.moderationService.refreshViewerFromClient === "function") {
|
|
this.moderationService.refreshViewerFromClient();
|
|
}
|
|
} catch (error) {
|
|
userLogger.warn("[nostrService] Failed to synchronize moderation service", error);
|
|
}
|
|
|
|
return this.moderationService;
|
|
}
|
|
|
|
ensureVideosMap() {
|
|
if (this.videosMap instanceof Map) {
|
|
return this.videosMap;
|
|
}
|
|
|
|
let map = getStoredVideosMap();
|
|
if (!(map instanceof Map)) {
|
|
map = new Map();
|
|
setStoredVideosMap(map);
|
|
}
|
|
|
|
this.videosMap = map;
|
|
this.markAuthorIndexDirty();
|
|
return this.videosMap;
|
|
}
|
|
|
|
getVideosMap() {
|
|
return this.ensureVideosMap();
|
|
}
|
|
|
|
getVideoSubscription() {
|
|
return getStoredVideoSubscription();
|
|
}
|
|
|
|
setVideoSubscription(subscription) {
|
|
setStoredVideoSubscription(subscription || null);
|
|
this.emit("subscription:changed", { subscription: subscription || null });
|
|
}
|
|
|
|
clearVideoSubscription() {
|
|
const current = getStoredVideoSubscription();
|
|
if (current && typeof current.unsub === "function") {
|
|
try {
|
|
current.unsub();
|
|
} catch (error) {
|
|
userLogger.warn("[nostrService] Failed to unsubscribe from video feed:", error);
|
|
}
|
|
}
|
|
this.setVideoSubscription(null);
|
|
}
|
|
|
|
cacheVideos(videos = []) {
|
|
if (!Array.isArray(videos) || !videos.length) {
|
|
return;
|
|
}
|
|
|
|
const map = this.ensureVideosMap();
|
|
for (const video of videos) {
|
|
if (video && typeof video.id === "string" && video.id) {
|
|
map.set(video.id, video);
|
|
}
|
|
}
|
|
setStoredVideosMap(map);
|
|
this.markAuthorIndexDirty();
|
|
this.emit("videos:cache", { size: map.size });
|
|
}
|
|
|
|
resetVideosCache() {
|
|
const map = new Map();
|
|
this.videosMap = map;
|
|
this.videosByAuthorIndex = new Map();
|
|
this.authorIndexDirty = false;
|
|
setStoredVideosMap(map);
|
|
this.emit("videos:cache", { size: 0 });
|
|
}
|
|
|
|
markAuthorIndexDirty() {
|
|
this.authorIndexDirty = true;
|
|
}
|
|
|
|
ensureVideosByAuthorIndex() {
|
|
const videosMap = this.ensureVideosMap();
|
|
|
|
if (this.videosByAuthorIndex instanceof Map && !this.authorIndexDirty) {
|
|
return this.videosByAuthorIndex;
|
|
}
|
|
|
|
const index = new Map();
|
|
for (const video of videosMap.values()) {
|
|
if (!video || typeof video !== "object") {
|
|
continue;
|
|
}
|
|
const author = normalizeHexPubkey(video.pubkey);
|
|
if (!author) {
|
|
continue;
|
|
}
|
|
if (!index.has(author)) {
|
|
index.set(author, []);
|
|
}
|
|
index.get(author).push(video);
|
|
}
|
|
|
|
this.videosByAuthorIndex = index;
|
|
this.authorIndexDirty = false;
|
|
return this.videosByAuthorIndex;
|
|
}
|
|
|
|
shouldIncludeVideo(video, {
|
|
blacklistedEventIds = new Set(),
|
|
isAuthorBlocked = () => false,
|
|
} = {}) {
|
|
if (!video || typeof video !== "object") {
|
|
return false;
|
|
}
|
|
|
|
const viewerIsAuthor = this.isViewerVideoAuthor(video);
|
|
|
|
if (ALLOW_NSFW_CONTENT !== true && video.isNsfw === true && !viewerIsAuthor) {
|
|
return false;
|
|
}
|
|
|
|
if (blacklistedEventIds.has(video.id)) {
|
|
return false;
|
|
}
|
|
|
|
if (typeof video.pubkey === "string" && video.pubkey) {
|
|
try {
|
|
if (isAuthorBlocked(video.pubkey)) {
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
userLogger.warn("[nostrService] isAuthorBlocked handler threw", error);
|
|
}
|
|
}
|
|
|
|
if (video.isPrivate === true && !viewerIsAuthor) {
|
|
return false;
|
|
}
|
|
|
|
if (this.accessControl && typeof this.accessControl.canAccess === "function") {
|
|
try {
|
|
if (!this.accessControl.canAccess(video)) {
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
userLogger.warn("[nostrService] access control check failed", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
filterVideos(videos = [], options = {}) {
|
|
const blacklist = ensureSet(options.blacklistedEventIds);
|
|
const isAuthorBlocked =
|
|
typeof options.isAuthorBlocked === "function"
|
|
? options.isAuthorBlocked
|
|
: () => false;
|
|
|
|
return videos.filter((video) =>
|
|
this.shouldIncludeVideo(video, {
|
|
blacklistedEventIds: blacklist,
|
|
isAuthorBlocked,
|
|
})
|
|
);
|
|
}
|
|
|
|
async ensureAccessControlReady() {
|
|
if (!this.accessControl || typeof this.accessControl.ensureReady !== "function") {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.accessControl.ensureReady();
|
|
} catch (error) {
|
|
userLogger.warn(
|
|
"[nostrService] Failed to ensure access control lists are ready:",
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
getFilteredActiveVideos(options = {}) {
|
|
const all = this.nostrClient.getActiveVideos();
|
|
return this.filterVideos(all, options);
|
|
}
|
|
|
|
getActiveVideosByAuthors(authors = [], options = {}) {
|
|
const candidates = ensureSet(authors);
|
|
const normalizedAuthors = new Set();
|
|
for (const candidate of candidates) {
|
|
const normalized = normalizeHexPubkey(candidate);
|
|
if (normalized) {
|
|
normalizedAuthors.add(normalized);
|
|
}
|
|
}
|
|
|
|
if (!normalizedAuthors.size) {
|
|
return this.getFilteredActiveVideos(options);
|
|
}
|
|
|
|
const index = this.ensureVideosByAuthorIndex();
|
|
const collected = [];
|
|
const seen = new Set();
|
|
|
|
for (const author of normalizedAuthors) {
|
|
const entries = index.get(author);
|
|
if (!Array.isArray(entries)) {
|
|
continue;
|
|
}
|
|
for (const video of entries) {
|
|
if (!video || typeof video !== "object") {
|
|
continue;
|
|
}
|
|
const id = typeof video.id === "string" ? video.id : "";
|
|
if (!id || seen.has(id)) {
|
|
continue;
|
|
}
|
|
seen.add(id);
|
|
collected.push(video);
|
|
}
|
|
}
|
|
|
|
const filtered = this.filterVideos(collected, options);
|
|
|
|
return filtered.sort((a, b) => {
|
|
const aCreatedCandidate = Number(a?.created_at);
|
|
const bCreatedCandidate = Number(b?.created_at);
|
|
const aCreated = Number.isFinite(aCreatedCandidate) ? aCreatedCandidate : 0;
|
|
const bCreated = Number.isFinite(bCreatedCandidate) ? bCreatedCandidate : 0;
|
|
return bCreated - aCreated;
|
|
});
|
|
}
|
|
|
|
async loadVideos({
|
|
forceFetch = false,
|
|
blacklistedEventIds,
|
|
isAuthorBlocked,
|
|
onVideos,
|
|
} = {}) {
|
|
this.ensureInitialLoadDeferred();
|
|
|
|
try {
|
|
await this.ensureAccessControlReady();
|
|
|
|
if (forceFetch) {
|
|
this.clearVideoSubscription();
|
|
}
|
|
|
|
const applyAndNotify = (videos, reason) => {
|
|
const filtered = this.filterVideos(videos, {
|
|
blacklistedEventIds,
|
|
isAuthorBlocked,
|
|
});
|
|
this.cacheVideos(filtered);
|
|
if (typeof onVideos === "function") {
|
|
try {
|
|
onVideos(filtered, { reason });
|
|
} catch (error) {
|
|
userLogger.warn("[nostrService] onVideos handler threw", error);
|
|
}
|
|
}
|
|
this.emit("videos:updated", { videos: filtered, reason });
|
|
return filtered;
|
|
};
|
|
|
|
const cached = this.nostrClient.getActiveVideos();
|
|
const initial = applyAndNotify(cached, "cache");
|
|
|
|
this.resolveInitialLoad({ videos: initial, reason: "cache" });
|
|
|
|
if (!getStoredVideoSubscription()) {
|
|
const subscription = this.nostrClient.subscribeVideos(() => {
|
|
const updated = this.nostrClient.getActiveVideos();
|
|
applyAndNotify(updated, "subscription");
|
|
});
|
|
this.setVideoSubscription(subscription);
|
|
this.emit("subscription:started", { subscription });
|
|
}
|
|
|
|
return initial;
|
|
} catch (error) {
|
|
this.resolveInitialLoad({ videos: [], reason: "error", error });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async fetchVideos(options = {}) {
|
|
try {
|
|
const videos = await this.nostrClient.fetchVideos();
|
|
const filtered = this.filterVideos(videos, options);
|
|
this.cacheVideos(filtered);
|
|
this.emit("videos:fetched", { videos: filtered });
|
|
return filtered;
|
|
} catch (error) {
|
|
userLogger.error("[nostrService] Failed to fetch videos:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async loadOlderVideos(lastTimestamp, {
|
|
blacklistedEventIds,
|
|
isAuthorBlocked,
|
|
limit = 150,
|
|
} = {}) {
|
|
const until = normalizeUntil(lastTimestamp) - 1;
|
|
if (until <= 0 || !this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) {
|
|
return [];
|
|
}
|
|
|
|
const filter = {
|
|
kinds: [VIDEO_KIND],
|
|
"#t": ["video"],
|
|
until,
|
|
limit,
|
|
};
|
|
|
|
const collected = new Map();
|
|
|
|
try {
|
|
const events = await this.nostrClient.pool.list(this.nostrClient.relays, [filter]);
|
|
for (const event of Array.isArray(events) ? events : []) {
|
|
try {
|
|
const video = convertEventToVideo(event);
|
|
if (video.invalid) {
|
|
continue;
|
|
}
|
|
if (
|
|
this.nostrClient &&
|
|
typeof this.nostrClient.applyRootCreatedAt === "function"
|
|
) {
|
|
this.nostrClient.applyRootCreatedAt(video);
|
|
}
|
|
if (collected.has(video.id)) {
|
|
continue;
|
|
}
|
|
collected.set(video.id, video);
|
|
this.nostrClient.allEvents.set(video.id, video);
|
|
} catch (error) {
|
|
userLogger.warn("[nostrService] Failed to convert older event", error);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
userLogger.error("[nostrService] Failed to load older videos:", error);
|
|
return [];
|
|
}
|
|
|
|
const videos = Array.from(collected.values());
|
|
const filtered = this.filterVideos(videos, {
|
|
blacklistedEventIds,
|
|
isAuthorBlocked,
|
|
});
|
|
|
|
this.cacheVideos(filtered);
|
|
this.emit("videos:older", { videos: filtered, until });
|
|
|
|
return filtered;
|
|
}
|
|
|
|
async publishVideoNote(publishPayload, pubkey) {
|
|
const detail = { pubkey };
|
|
if (publishPayload && typeof publishPayload === "object") {
|
|
detail.payload = publishPayload;
|
|
if (publishPayload.legacyFormData) {
|
|
detail.legacyFormData = publishPayload.legacyFormData;
|
|
}
|
|
if (publishPayload.nip71) {
|
|
detail.nip71 = publishPayload.nip71;
|
|
}
|
|
} else {
|
|
detail.formData = publishPayload;
|
|
}
|
|
|
|
let nip71Result = null;
|
|
let legacyResult = null;
|
|
|
|
try {
|
|
legacyResult = await this.nostrClient.publishVideo(
|
|
publishPayload,
|
|
pubkey
|
|
);
|
|
} catch (error) {
|
|
detail.error = error;
|
|
throw error;
|
|
}
|
|
|
|
let shouldAttemptNip71 = true;
|
|
const pointerIdentifiers = {
|
|
eventId: legacyResult?.id,
|
|
};
|
|
|
|
if (Array.isArray(legacyResult?.tags)) {
|
|
const dTag = legacyResult.tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "d" && typeof tag[1] === "string"
|
|
);
|
|
if (dTag) {
|
|
pointerIdentifiers.dTag = dTag[1];
|
|
}
|
|
}
|
|
|
|
if (legacyResult?.content) {
|
|
try {
|
|
const parsed = JSON.parse(legacyResult.content);
|
|
if (parsed && typeof parsed.videoRootId === "string") {
|
|
pointerIdentifiers.videoRootId = parsed.videoRootId;
|
|
}
|
|
if (parsed && parsed.isPrivate) {
|
|
shouldAttemptNip71 = false;
|
|
}
|
|
} catch (parseError) {
|
|
this.log("[nostrService] Failed to parse legacy publish payload", parseError);
|
|
}
|
|
}
|
|
|
|
if (!pointerIdentifiers.videoRootId) {
|
|
shouldAttemptNip71 = false;
|
|
}
|
|
|
|
if (shouldAttemptNip71) {
|
|
try {
|
|
nip71Result = await this.nostrClient.publishNip71Video(
|
|
publishPayload,
|
|
pubkey,
|
|
pointerIdentifiers
|
|
);
|
|
} catch (nip71Error) {
|
|
detail.nip71Error = nip71Error;
|
|
this.log("[nostrService] NIP-71 publish failed", nip71Error);
|
|
}
|
|
}
|
|
|
|
const result = { legacy: legacyResult, nip71: nip71Result };
|
|
detail.result = result;
|
|
this.emit("videos:published", detail);
|
|
return result;
|
|
}
|
|
|
|
async handleEditVideoSubmit({ originalEvent, updatedData, pubkey }) {
|
|
const result = await this.nostrClient.editVideo(originalEvent, updatedData, pubkey);
|
|
this.emit("videos:edited", { originalEvent, updatedData, pubkey, result });
|
|
return result;
|
|
}
|
|
|
|
async handleFullDeleteVideo({ videoRootId, video, pubkey, confirm = true } = {}) {
|
|
const result = await this.nostrClient.deleteAllVersions(videoRootId, pubkey, {
|
|
confirm,
|
|
video,
|
|
});
|
|
if (result) {
|
|
this.emit("videos:deleted", { videoRootId, video, pubkey });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async getOldEventById(eventId) {
|
|
const map = this.ensureVideosMap();
|
|
const isBlockedNsfw = (video) =>
|
|
ALLOW_NSFW_CONTENT !== true &&
|
|
video?.isNsfw === true &&
|
|
!this.isViewerVideoAuthor(video);
|
|
|
|
if (map.has(eventId)) {
|
|
const existing = map.get(eventId);
|
|
if (isBlockedNsfw(existing)) {
|
|
map.delete(eventId);
|
|
setStoredVideosMap(map);
|
|
return null;
|
|
}
|
|
return existing;
|
|
}
|
|
|
|
const cached = this.nostrClient.allEvents.get(eventId);
|
|
if (cached && !cached.deleted) {
|
|
if (isBlockedNsfw(cached)) {
|
|
return null;
|
|
}
|
|
map.set(eventId, cached);
|
|
setStoredVideosMap(map);
|
|
return cached;
|
|
}
|
|
|
|
const fetched = await this.nostrClient.getEventById(eventId);
|
|
if (fetched && !fetched.deleted) {
|
|
if (isBlockedNsfw(fetched)) {
|
|
return null;
|
|
}
|
|
map.set(eventId, fetched);
|
|
setStoredVideosMap(map);
|
|
return fetched;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
isViewerVideoAuthor(video) {
|
|
try {
|
|
this.getModerationService();
|
|
} catch (error) {
|
|
userLogger.warn("[nostrService] Failed to refresh moderation context", error);
|
|
}
|
|
if (!video || typeof video !== "object") {
|
|
return false;
|
|
}
|
|
|
|
const viewerPubkey = normalizeHexPubkey(this.nostrClient?.pubkey);
|
|
if (!viewerPubkey) {
|
|
return false;
|
|
}
|
|
|
|
const videoPubkey = normalizeHexPubkey(video.pubkey);
|
|
return !!videoPubkey && videoPubkey === viewerPubkey;
|
|
}
|
|
}
|
|
|
|
const nostrService = new NostrService();
|
|
|
|
export default nostrService;
|