From b56cd0c95fb2fe9f0e9ad4b6226df2dc67060955 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:22:54 -0400 Subject: [PATCH] Add playback view logging via Nostr helpers --- js/app.js | 287 +++++++++++++++++++++++++++++++++++++++++++++++++++- js/nostr.js | 6 ++ 2 files changed, 292 insertions(+), 1 deletion(-) diff --git a/js/app.js b/js/app.js index 1ec336e6..bba4a79e 100644 --- a/js/app.js +++ b/js/app.js @@ -1,7 +1,11 @@ // js/app.js import { loadView } from "./viewManager.js"; -import { nostrClient } from "./nostr.js"; +import { + nostrClient, + recordVideoView, + updateWatchHistoryList, +} from "./nostr.js"; import { torrentClient } from "./webtorrent.js"; import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js"; import { accessControl, normalizeNpub } from "./accessControl.js"; @@ -54,6 +58,30 @@ function truncateMiddle(text, maxLength = 72) { return `${text.slice(0, front)}${ellipsis}${text.slice(text.length - back)}`; } +function pointerArrayToKey(pointer) { + if (!Array.isArray(pointer) || pointer.length < 2) { + return ""; + } + + const type = pointer[0] === "a" ? "a" : pointer[0] === "e" ? "e" : ""; + if (!type) { + return ""; + } + + const value = + typeof pointer[1] === "string" ? pointer[1].trim().toLowerCase() : ""; + if (!value) { + return ""; + } + + const relay = + pointer.length > 2 && typeof pointer[2] === "string" + ? pointer[2].trim() + : ""; + + return relay ? `${type}:${value}:${relay}` : `${type}:${value}`; +} + /** * Simple "decryption" placeholder for private videos. */ @@ -671,6 +699,10 @@ class bitvidApp { this.pubkey = null; this.currentMagnetUri = null; this.currentVideo = null; + this.currentVideoPointer = null; + this.currentVideoPointerKey = null; + this.playbackTelemetryState = null; + this.loggedViewPointerKeys = new Set(); this.videoSubscription = null; this.videoList = null; this._videoListElement = null; @@ -6261,6 +6293,7 @@ class bitvidApp { `[cleanup] Begin (preserveSubscriptions=${preserveSubscriptions}, preserveObservers=${preserveObservers})` ); try { + this.cancelPendingViewLogging(); this.clearActiveIntervals(); this.cleanupUrlPlaybackWatchdog(); @@ -6358,6 +6391,209 @@ class bitvidApp { } } + cancelPendingViewLogging() { + const state = this.playbackTelemetryState; + if (!state) { + return; + } + + if (state.timerId) { + clearTimeout(state.timerId); + } + + if (Array.isArray(state.handlers) && state.videoEl) { + for (const { eventName, handler } of state.handlers) { + try { + state.videoEl.removeEventListener(eventName, handler); + } catch (err) { + if (isDevMode) { + console.warn( + `[cancelPendingViewLogging] Failed to detach ${eventName} listener:`, + err + ); + } + } + } + } + + this.playbackTelemetryState = null; + } + + preparePlaybackViewLogging(videoEl) { + this.cancelPendingViewLogging(); + + if (!videoEl || typeof videoEl.addEventListener !== "function") { + return; + } + + const pointer = this.currentVideoPointer; + const pointerKey = this.currentVideoPointerKey || pointerArrayToKey(pointer); + if (!pointer || !pointerKey) { + return; + } + + if (this.loggedViewPointerKeys.has(pointerKey)) { + return; + } + + const THRESHOLD_SECONDS = 12; + const state = { + videoEl, + pointer, + pointerKey, + handlers: [], + timerId: null, + fired: false, + }; + + const finalize = () => { + if (state.fired) { + return; + } + state.fired = true; + cancelTimer(); + this.cancelPendingViewLogging(); + + const { pointer: thresholdPointer, pointerKey: thresholdPointerKey } = + state; + + (async () => { + try { + const result = await recordVideoView(thresholdPointer); + const viewOk = !!result?.view?.ok; + if (viewOk) { + this.loggedViewPointerKeys.add(thresholdPointerKey); + } else if (isDevMode) { + console.warn( + "[playVideoWithFallback] View event rejected by relays:", + result?.view || result + ); + } + + let historyOk = !!result?.history?.ok; + if (!historyOk) { + if (isDevMode) { + console.warn( + "[playVideoWithFallback] Watch history update via recordVideoView failed; retrying.", + result?.history || result + ); + } + const fallbackHistory = await updateWatchHistoryList( + thresholdPointer + ); + historyOk = !!fallbackHistory?.ok; + if (!historyOk && isDevMode) { + console.warn( + "[playVideoWithFallback] Watch history retry failed:", + fallbackHistory + ); + } + } + + if (!viewOk && isDevMode && historyOk) { + console.warn( + "[playVideoWithFallback] Watch history updated but view event was rejected." + ); + } + } catch (error) { + if (isDevMode) { + console.warn( + "[playVideoWithFallback] Exception while recording video view:", + error + ); + } + try { + const fallbackHistory = await updateWatchHistoryList( + thresholdPointer + ); + if (!fallbackHistory?.ok && isDevMode) { + console.warn( + "[playVideoWithFallback] Watch history retry after view exception failed:", + fallbackHistory + ); + } + } catch (historyError) { + if (isDevMode) { + console.warn( + "[playVideoWithFallback] Exception while retrying watch history after view failure:", + historyError + ); + } + } + } + })().catch((error) => { + if (isDevMode) { + console.warn( + "[playVideoWithFallback] Unexpected error while recording video view:", + error + ); + } + }); + }; + + const cancelTimer = () => { + if (state.timerId) { + clearTimeout(state.timerId); + state.timerId = null; + } + }; + + const scheduleTimer = () => { + if (state.fired) { + return; + } + const currentSeconds = Number.isFinite(videoEl.currentTime) + ? videoEl.currentTime + : 0; + const remainingMs = Math.max( + 0, + Math.ceil((THRESHOLD_SECONDS - currentSeconds) * 1000) + ); + if (remainingMs <= 0) { + finalize(); + return; + } + state.timerId = window.setTimeout(finalize, remainingMs); + }; + + const registerHandler = (eventName, handler) => { + videoEl.addEventListener(eventName, handler); + state.handlers.push({ eventName, handler }); + }; + + registerHandler("timeupdate", () => { + if (videoEl.currentTime >= THRESHOLD_SECONDS) { + finalize(); + } + }); + + const cancelOnPause = () => { + if (!state.fired) { + cancelTimer(); + } + }; + + ["pause", "waiting", "stalled", "ended", "emptied"].forEach((event) => + registerHandler(event, cancelOnPause) + ); + + const resumeIfNeeded = () => { + if (!state.fired && !state.timerId) { + scheduleTimer(); + } + }; + + ["play", "playing"].forEach((event) => + registerHandler(event, resumeIfNeeded) + ); + + this.playbackTelemetryState = state; + + if (!videoEl.paused && videoEl.currentTime > 0) { + resumeIfNeeded(); + } + } + teardownVideoElement(videoElement, { replaceNode = false } = {}) { if (!videoElement) { this.log( @@ -6774,6 +7010,7 @@ class bitvidApp { */ async hideModal() { // 1) Clear intervals, cleanup, etc. (unchanged) + this.cancelPendingViewLogging(); this.clearActiveIntervals(); try { @@ -8963,6 +9200,7 @@ class bitvidApp { // blank frame. Guard every playback entry point with waitForCleanup() so the // race never reappears. await this.waitForCleanup(); + this.cancelPendingViewLogging(); const sanitizedUrl = typeof url === "string" ? url.trim() : ""; const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; @@ -9089,6 +9327,7 @@ class bitvidApp { this.resetTorrentStats(); this.playSource = null; this.cleanupUrlPlaybackWatchdog(); + this.preparePlaybackViewLogging(activeVideoEl); if (magnetForPlayback) { const label = playbackConfig.didMutate @@ -9378,6 +9617,9 @@ class bitvidApp { return; } + this.currentVideoPointer = null; + this.currentVideoPointerKey = null; + if (this.blacklistedEventIds.has(eventId)) { this.showError("This content has been removed or is not allowed."); return; @@ -9447,6 +9689,44 @@ class bitvidApp { lightningAddress: null, }; + const dTagValue = (this.extractDTagValue(video.tags) || "").trim(); + const normalizedPubkey = + typeof video.pubkey === "string" ? video.pubkey.trim() : ""; + const primaryPointer = + dTagValue && normalizedPubkey + ? [ + "a", + `${ + typeof video.kind === "number" && Number.isFinite(video.kind) + ? video.kind + : 30078 + }:${normalizedPubkey}:${dTagValue}`, + ] + : null; + const fallbackId = + typeof (video.id || eventId) === "string" + ? (video.id || eventId).trim() + : ""; + const fallbackPointer = + !primaryPointer && fallbackId + ? ["e", fallbackId] + : null; + + const resolvedPointer = primaryPointer || fallbackPointer; + const resolvedPointerKey = pointerArrayToKey(resolvedPointer); + + this.currentVideoPointer = resolvedPointer && resolvedPointerKey + ? resolvedPointer + : null; + this.currentVideoPointerKey = this.currentVideoPointer + ? resolvedPointerKey + : null; + + if (this.currentVideo) { + this.currentVideo.pointer = this.currentVideoPointer; + this.currentVideo.pointerKey = this.currentVideoPointerKey; + } + this.syncModalMoreMenuData(); this.currentMagnetUri = sanitizedMagnet || null; @@ -9525,6 +9805,9 @@ class bitvidApp { title = "Untitled", description = "", } = {}) { + this.currentVideoPointer = null; + this.currentVideoPointerKey = null; + const sanitizedUrl = typeof url === "string" ? url.trim() : ""; const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; const decodedMagnet = safeDecodeMagnet(trimmedMagnet); @@ -9562,6 +9845,8 @@ class bitvidApp { originalMagnet: trimmedMagnet, torrentSupported: magnetSupported, lightningAddress: null, + pointer: null, + pointerKey: null, }; this.syncModalMoreMenuData(); diff --git a/js/nostr.js b/js/nostr.js index 9359a91a..93188edc 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2675,3 +2675,9 @@ class NostrClient { } export const nostrClient = new NostrClient(); + +export const recordVideoView = (...args) => + nostrClient.recordVideoView(...args); + +export const updateWatchHistoryList = (...args) => + nostrClient.updateWatchHistoryList(...args);