Files
bitvid/js/gridHealth.js
T

443 lines
10 KiB
JavaScript

import { infoHashFromMagnet } from "./magnets.js";
import { TorrentClient, torrentClient } from "./webtorrent.js";
const containerState = new WeakMap();
const ROOT_MARGIN = "200px 0px";
function now() {
return Date.now();
}
function getCacheEntry(key) {
if (!key) {
return null;
}
const entry = probeCache.get(key);
if (!entry) {
return null;
}
if (now() - entry.ts > PROBE_CACHE_TTL_MS) {
probeCache.delete(key);
return null;
}
return entry.value;
}
function setCacheEntry(key, value) {
if (!key) {
return;
}
probeCache.set(key, { ts: now(), value });
}
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 } = {}) {
const parts = [];
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",
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;
const healthy = Boolean(result.healthy) && peers > 0;
const reason = typeof result.reason === "string" ? result.reason : "error";
return {
healthy,
peers: healthy ? Math.max(1, peers) : peers,
reason,
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) {
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,
})
.catch((err) => ({
healthy: false,
peers: 0,
reason: "error",
error: err,
appendedTrackers: false,
hasProbeTrackers: false,
}))
)
.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 = 5 * 60 * 1000;
const PROBE_TIMEOUT_MS = 8000;
const PROBE_CONCURRENCY = 3;
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) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this.drain();
});
}
drain() {
if (this.running >= this.max) {
return;
}
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();
return;
}
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 ensureState(container) {
let state = containerState.get(container);
if (state) {
return state;
}
const pendingByCard = new WeakMap();
const observedCards = new WeakSet();
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const card = entry.target;
if (!(card instanceof HTMLElement)) {
return;
}
if (!entry.isIntersecting) {
return;
}
handleCardVisible({ card, pendingByCard });
});
},
{ root: null, rootMargin: ROOT_MARGIN, threshold: 0.01 }
);
state = { observer, pendingByCard, observedCards };
containerState.set(container, state);
return state;
}
function setBadge(card, state, details) {
const badge = card.querySelector(".torrent-health-badge");
if (!badge) {
return;
}
const hadMargin = badge.classList.contains("mt-3");
const baseClasses = [
"torrent-health-badge",
"text-xs",
"font-semibold",
"px-2",
"py-1",
"rounded",
"inline-flex",
"items-center",
"gap-1",
"transition-colors",
"duration-200",
];
if (hadMargin) {
baseClasses.unshift("mt-3");
}
badge.className = baseClasses.join(" ");
const map = {
healthy: {
icon: "🟢",
aria: "WebTorrent peers available",
classes: ["bg-green-900", "text-green-200"],
role: "status",
},
unhealthy: {
icon: "🔴",
aria: "WebTorrent peers unavailable",
classes: ["bg-red-900", "text-red-200"],
role: "alert",
},
checking: {
icon: "⏳",
aria: "Checking WebTorrent peers",
classes: ["bg-gray-800", "text-gray-300"],
role: "status",
},
unknown: {
icon: "⚪",
aria: "WebTorrent status unknown",
classes: ["bg-gray-800", "text-gray-300"],
role: "status",
},
};
const entry = map[state] || map.unknown;
entry.classes.forEach((cls) => badge.classList.add(cls));
const peers =
details && Number.isFinite(details.peers)
? Math.max(0, Number(details.peers))
: 0;
const peersText = state === "healthy" && peers > 0 ? ` (${peers})` : "";
const iconPrefix = entry.icon ? `${entry.icon} ` : "";
badge.textContent = `${iconPrefix}WebTorrent${peersText}`;
const tooltip =
state === "checking" || state === "unknown"
? entry.aria
: buildTooltip({
peers,
checkedAt: details?.checkedAt,
reason: details?.reason,
});
badge.setAttribute("aria-label", tooltip);
badge.setAttribute("title", tooltip);
badge.setAttribute("aria-live", entry.role === "alert" ? "assertive" : "polite");
badge.setAttribute("role", entry.role);
badge.dataset.streamHealthState = state;
if (Number.isFinite(peers)) {
badge.dataset.streamHealthPeers = String(peers);
} else if (badge.dataset.streamHealthPeers) {
delete badge.dataset.streamHealthPeers;
}
}
function handleCardVisible({ card, pendingByCard }) {
if (!(card instanceof HTMLElement)) {
return;
}
const magnet = card.dataset.magnet || "";
if (!magnet) {
setBadge(card, "unknown");
return;
}
const infoHash = infoHashFromMagnet(magnet);
if (!infoHash) {
setBadge(card, "unknown");
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 probePromise = queueProbe(magnet, infoHash);
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) => {
console.warn("[gridHealth] probe failed", err);
pendingByCard.delete(card);
if (!card.isConnected) {
return;
}
setBadge(card, "unhealthy");
});
}
export function attachHealthBadges(container) {
if (!(container instanceof HTMLElement)) {
return;
}
const state = ensureState(container);
const cards = container.querySelectorAll(".video-card");
cards.forEach((card) => {
if (!(card instanceof HTMLElement)) {
return;
}
if (state.observedCards.has(card)) {
return;
}
state.observedCards.add(card);
state.observer.observe(card);
if (!card.dataset.magnet) {
setBadge(card, "unknown");
}
});
}
export function refreshHealthBadges(container) {
if (!(container instanceof HTMLElement)) {
return;
}
const state = containerState.get(container);
if (!state) {
return;
}
state.observer.takeRecords().forEach((entry) => {
if (entry.isIntersecting) {
handleCardVisible({ card: entry.target, pendingByCard: state.pendingByCard });
}
});
}