import { createCardObserver } from "./dom/cardObserver.js"; import { infoHashFromMagnet } from "./magnets.js"; import { updateVideoCardSourceVisibility, cardNeedsEagerLivenessProbe, } from "./utils/cardSourceVisibility.js"; import { TorrentClient, torrentClient } from "./webtorrent.js"; import { userLogger } from "./utils/logger.js"; import { readTorrentProbeFromStorage, removeTorrentProbeFromStorage, writeTorrentProbeToStorage, } from "./utils/storage.js"; import { prioritizeEntries, getViewportCenter, PRIORITY_BASELINE, } from "./gridHealthLogic.js"; import { FIVE_MINUTES_MS, getLivenessProbePrefetchMargin, } from "./constants.js"; const badgeUpdateListeners = new Set(); function notifyBadgeUpdate(payload) { if (!payload) { return; } badgeUpdateListeners.forEach((listener) => { if (typeof listener !== "function") { return; } try { listener(payload); } catch (err) { userLogger.warn("[gridHealth] badge listener failed", err); } }); } // Configurable prefetch margin so cards probe ahead of the scroll (WebTorrent // probes stay concurrency-capped + priority-ordered, so a larger margin only // reorders work). Falls back to the existing 200px vertical prefetch. const ROOT_MARGIN = getLivenessProbePrefetchMargin() || "200px 0px"; function now() { return Date.now(); } function getCacheEntry(key) { if (!key) { return null; } let entry = probeCache.get(key); if (!entry) { // Try storage if memory miss entry = readTorrentProbeFromStorage(key); } if (!entry) { return null; } // Verify freshness if (now() - entry.ts > PROBE_CACHE_TTL_MS) { probeCache.delete(key); removeTorrentProbeFromStorage(key); return null; } // Populate memory if it came from storage if (!probeCache.has(key)) { probeCache.set(key, entry); } return entry.value; } function setCacheEntry(key, value) { if (!key) { return; } const entry = { ts: now(), value }; probeCache.set(key, entry); writeTorrentProbeToStorage(key, entry); } function formatTime(ts) { if (!Number.isFinite(ts)) { return ""; } try { return new Date(ts).toLocaleTimeString([], { hour12: false }); } catch (err) { return new Date(ts).toLocaleTimeString(); } } function buildTooltip({ peers = 0, checkedAt, reason, webseedOnly } = {}) { const parts = []; if (webseedOnly) { parts.push("Webseed only"); } else if (Number.isFinite(peers)) { parts.push(`Peers: ${Math.max(0, peers)}`); } if (Number.isFinite(checkedAt)) { const formatted = formatTime(checkedAt); if (formatted) { parts.push(`Checked ${formatted}`); } } if (typeof reason === "string" && reason && reason !== "peer") { let normalizedReason; if (reason === "timeout") { normalizedReason = "Timed out"; } else if (reason === "no-trackers") { normalizedReason = "No WSS trackers"; } else if (reason === "invalid") { normalizedReason = "Invalid magnet"; } else { normalizedReason = reason.charAt(0).toUpperCase() + reason.slice(1); } parts.push(normalizedReason); } if (!parts.length) { return "WebTorrent status unknown"; } return `WebTorrent • ${parts.join(" • ")}`; } function normalizeResult(result) { const fallback = { healthy: false, peers: 0, reason: "error", webseedOnly: false, appendedTrackers: false, hasProbeTrackers: false, usedTrackers: Array.isArray(TorrentClient.PROBE_TRACKERS) ? [...TorrentClient.PROBE_TRACKERS] : [], durationMs: 0, }; if (!result || typeof result !== "object") { return { ...fallback, checkedAt: now() }; } const peers = Number.isFinite(result.peers) ? Math.max(0, Number(result.peers)) : 0; /** * CRITICAL: Health determination relies SOLELY on `peers > 0`. * Because we pass webseeds to the client, a connected webseed counts as a peer. * * Regression warning: * Do not attempt to add `|| webseedOnly` here. If `peers` is 0, the webseed * did not connect, and the stream is NOT healthy. */ const healthy = Boolean(result.healthy) && peers > 0; const reason = typeof result.reason === "string" ? result.reason : "error"; const webseedOnly = Boolean(result.webseedOnly) && peers === 0; return { healthy, peers: healthy ? Math.max(1, peers) : peers, reason, webseedOnly, appendedTrackers: Boolean(result.appendedTrackers), hasProbeTrackers: typeof result.hasProbeTrackers === "boolean" ? result.hasProbeTrackers : healthy || Boolean(result.appendedTrackers), usedTrackers: Array.isArray(result.usedTrackers) ? result.usedTrackers.slice() : fallback.usedTrackers, durationMs: Number.isFinite(result.durationMs) ? Math.max(0, Number(result.durationMs)) : 0, checkedAt: now(), }; } function queueProbe(magnet, cacheKey, priority = 0, webSeeds = []) { if (!magnet) { return Promise.resolve(null); } const cached = getCacheEntry(cacheKey); if (cached) { return Promise.resolve(cached); } if (probeInflight.has(cacheKey)) { return probeInflight.get(cacheKey); } const job = probeQueue .run( () => torrentClient .probePeers(magnet, { timeoutMs: PROBE_TIMEOUT_MS, maxWebConns: 2, polls: PROBE_POLL_COUNT, urlList: webSeeds, }) .catch((err) => ({ healthy: false, peers: 0, reason: "error", error: err, appendedTrackers: false, hasProbeTrackers: false, })), priority ) .then((result) => { const normalized = normalizeResult(result); if (cacheKey) { setCacheEntry(cacheKey, normalized); } return normalized; }) .finally(() => { if (cacheKey) { probeInflight.delete(cacheKey); } }); if (cacheKey) { probeInflight.set(cacheKey, job); } return job; } const PROBE_CACHE_TTL_MS = FIVE_MINUTES_MS; const PROBE_TIMEOUT_MS = 20000; // Each probe is a real WebTorrent swarm join (tracker announces + peer // connections), NOT a cheap ping. At 96 concurrent, loading/scrolling the feed // opened hundreds-to-thousands of simultaneous WebSocket/WebRTC connections, // pegging the main thread and exhausting memory (freeze + tab crash). Keep this // small — badges still populate, just a few at a time. const PROBE_CONCURRENCY = 4; const PROBE_POLL_COUNT = 3; class ProbeQueue { constructor(max = 2) { this.max = Math.max(1, Number(max) || 1); this.running = 0; this.queue = []; } run(task, priority = 0) { return new Promise((resolve, reject) => { const normalizedPriority = Number.isFinite(priority) ? priority : 0; const job = { task, resolve, reject, priority: normalizedPriority, }; this.enqueue(job); this.drain(); }); } enqueue(job) { if (!this.queue.length) { this.queue.push(job); return; } const index = this.queue.findIndex( (existing) => existing.priority < job.priority ); if (index === -1) { this.queue.push(job); } else { this.queue.splice(index, 0, job); } } drain() { while (this.running < this.max && this.queue.length > 0) { const job = this.queue.shift(); if (!job) { return; } this.running += 1; let finished = false; const finalize = () => { if (finished) { return; } finished = true; this.running -= 1; this.drain(); }; let result; try { result = job.task(); } catch (err) { job.reject(err); finalize(); continue; } Promise.resolve(result) .then((value) => { job.resolve(value); }) .catch((err) => { job.reject(err); }) .finally(() => { finalize(); }); } } } const probeQueue = new ProbeQueue(PROBE_CONCURRENCY); const probeCache = new Map(); const probeInflight = new Map(); function setBadge(card, state, details) { if (!(card instanceof HTMLElement)) { return; } const normalizedState = typeof state === "string" && state ? state : "unknown"; const peersValue = details && Number.isFinite(details.peers) ? Math.max(0, Number(details.peers)) : 0; const hasPeerCount = details ? Number.isFinite(details.peers) : false; const peersTextValue = hasPeerCount ? String(peersValue) : ""; const webseedOnly = Boolean(details?.webseedOnly); card.dataset.streamHealthState = normalizedState; if (hasPeerCount) { card.dataset.streamHealthPeers = peersTextValue; } else if (card.dataset.streamHealthPeers) { delete card.dataset.streamHealthPeers; } if (details && typeof details.reason === "string" && details.reason) { card.dataset.streamHealthReason = details.reason; } else if (card.dataset.streamHealthReason) { delete card.dataset.streamHealthReason; } updateVideoCardSourceVisibility(card); const badge = card.querySelector(".torrent-health-badge"); if (!badge) { return; } const hadCompactMargin = badge.classList.contains("mt-sm") || badge.classList.contains("mt-3"); const classes = ["badge", "torrent-health-badge"]; if (hadCompactMargin) { classes.push("mt-sm"); } badge.className = classes.join(" "); const map = { healthy: { icon: "🟢", aria: "WebTorrent peers available", variant: "success", role: "status", }, unhealthy: { icon: "🔴", aria: "WebTorrent peers unavailable", variant: "critical", role: "alert", }, checking: { icon: "⏳", aria: "Checking WebTorrent peers", variant: "neutral", role: "status", }, unknown: { icon: "⚪", aria: "WebTorrent status unknown", variant: "neutral", role: "status", }, }; const entry = map[normalizedState] || map.unknown; if (entry.variant) { badge.dataset.variant = entry.variant; } else if (badge.dataset.variant) { delete badge.dataset.variant; } const iconPrefix = entry.icon ? `${entry.icon} ` : ""; badge.textContent = `${iconPrefix}WebTorrent`; const tooltip = normalizedState === "checking" || normalizedState === "unknown" ? entry.aria : buildTooltip({ peers: peersValue, checkedAt: details?.checkedAt, reason: details?.reason, webseedOnly, }); badge.setAttribute("aria-label", tooltip); badge.setAttribute("title", tooltip); const ariaLive = entry.role === "alert" ? "assertive" : "polite"; badge.setAttribute("aria-live", ariaLive); badge.setAttribute("role", entry.role); badge.dataset.streamHealthState = normalizedState; if (hasPeerCount) { badge.dataset.streamHealthPeers = peersTextValue; } else if (badge.dataset.streamHealthPeers) { delete badge.dataset.streamHealthPeers; } const payload = { card, state: normalizedState, peers: hasPeerCount ? peersValue : null, reason: details && typeof details.reason === "string" ? details.reason : null, webseedOnly, checkedAt: details && Number.isFinite(details.checkedAt) ? Number(details.checkedAt) : null, text: badge.textContent, tooltip, role: entry.role, ariaLive, }; notifyBadgeUpdate(payload); } function handleCardVisible({ card, pendingByCard, priority = 0 }) { if (!(card instanceof HTMLElement)) { return; } const magnet = card.dataset.magnet || ""; if (!magnet) { setBadge(card, "unhealthy", { reason: "missing-source" }); return; } const infoHash = infoHashFromMagnet(magnet); if (!infoHash) { setBadge(card, "unhealthy", { reason: "invalid" }); return; } if (!(pendingByCard instanceof WeakMap)) { return; } if (pendingByCard.has(card)) { return; } const cached = getCacheEntry(infoHash); if (cached) { const cachedState = cached.healthy ? "healthy" : cached.hasProbeTrackers ? "unhealthy" : "unknown"; setBadge(card, cachedState, cached); return; } setBadge(card, "checking"); const encodedUrl = card.dataset.urlHealthUrl || ""; const webSeed = encodedUrl ? decodeURIComponent(encodedUrl) : ""; const webSeeds = webSeed ? [webSeed] : []; const probePromise = queueProbe(magnet, infoHash, priority, webSeeds); pendingByCard.set(card, probePromise); probePromise .then((result) => { pendingByCard.delete(card); if (!card.isConnected) { return; } if (!result) { setBadge(card, "unknown"); return; } if (!result.hasProbeTrackers) { setBadge(card, "unknown", result); return; } if (result.healthy) { setBadge(card, "healthy", result); return; } setBadge(card, "unhealthy", result); }) .catch((err) => { userLogger.warn("[gridHealth] probe failed", err); pendingByCard.delete(card); if (!card.isConnected) { return; } setBadge(card, "unhealthy"); }); } const gridCardObserver = createCardObserver({ rootMargin: ROOT_MARGIN, threshold: 0.01, createState: () => ({ pendingByCard: new WeakMap() }), prepareEntries: (entries) => { const viewportCenter = getViewportCenter(); return prioritizeEntries(entries, viewportCenter); }, isCardVisible: (entry) => { if (!entry) { return false; } const isIntersecting = Boolean(entry.isIntersecting); const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; return isIntersecting && ratio > 0; }, onCardRegister: ({ card, state }) => { if (!card.dataset.magnet) { setBadge(card, "unhealthy", { reason: "missing-source" }); return; } // Hide-until-verified cards start hidden, so the viewport observer can never // fire for them — probe eagerly on register (still concurrency-capped) so // they can be verified and revealed instead of staying hidden forever. if ( cardNeedsEagerLivenessProbe(card) && state && state.pendingByCard instanceof WeakMap ) { handleCardVisible({ card, pendingByCard: state.pendingByCard, priority: PRIORITY_BASELINE, }); } }, onCardVisible: ({ card, meta, state }) => { if (!state || !(state.pendingByCard instanceof WeakMap)) { return; } const priority = meta && typeof meta === "object" && Number.isFinite(meta.priority) ? meta.priority : PRIORITY_BASELINE; handleCardVisible({ card, pendingByCard: state.pendingByCard, priority, }); }, }); export function attachHealthBadges(container, options = {}) { if (!(container instanceof HTMLElement)) { return; } if (options && typeof options.onUpdate === "function") { badgeUpdateListeners.add(options.onUpdate); } gridCardObserver.observe(container); }