Add tracker health badges for magnets

This commit is contained in:
thePR0M3TH3AN committed 2025-09-25 18:50:12 -04:00
1 parent 0ad6d53ebb
commit ddd8a393f4
10 files changed
+724 -2

No files matched your search

+38
View File
@@ -74,6 +74,44 @@ header img {
box-shadow: var(--shadow-md);
}
.video-card .stream-health {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
font-size: 1.1rem;
line-height: 1;
transition: transform 150ms ease, filter 150ms ease, opacity 150ms ease;
}
.video-card .stream-health[data-stream-health-state="good"] {
filter: drop-shadow(0 0 6px rgba(34, 197, 94, 0.45));
}
.video-card .stream-health[data-stream-health-state="none"] {
opacity: 0.85;
}
.video-card .stream-health[data-stream-health-state="noresp"],
.video-card .stream-health[data-stream-health-state="unknown"] {
opacity: 0.6;
}
.video-card .stream-health[data-stream-health-state="checking"] {
animation: stream-health-pulse 1.2s ease-in-out infinite alternate;
}
@keyframes stream-health-pulse {
from {
transform: scale(0.95);
opacity: 0.65;
}
to {
transform: scale(1.05);
opacity: 1;
}
}
.video-card--enter {
opacity: 0;
animation: video-card-fade-in 220ms ease-out forwards;
+21
View File
@@ -10,6 +10,7 @@ import { normalizeAndAugmentMagnet } from "./magnet.js";
import { deriveTorrentPlaybackConfig } from "./playbackUtils.js";
import { URL_FIRST_ENABLED } from "./constants.js";
import { trackVideoView } from "./analytics.js";
import { attachHealthBadges } from "./gridHealth.js";
import {
initialWhitelist,
initialBlacklist,
@@ -2545,6 +2546,19 @@ class bitvidApp {
</div>
</a>
<div class="p-4">
<div class="flex items-center justify-between text-xs text-gray-500 mb-2">
<span class="uppercase tracking-wide text-[0.65rem] text-gray-500">
Streamable?
</span>
<span
class="stream-health text-lg"
aria-live="polite"
aria-label="Checking stream availability"
title="Checking stream availability"
>
🟦
</span>
</div>
<!-- Title triggers the video modal as well -->
<h3
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
@@ -2687,6 +2701,12 @@ class bitvidApp {
} else if (magnetProvided && magnetSupported) {
cardEl.dataset.torrentSupported = "true";
}
if (magnetProvided) {
cardEl.dataset.magnet = playbackMagnet;
} else if (cardEl.dataset.magnet) {
delete cardEl.dataset.magnet;
}
const interactiveEls = cardEl.querySelectorAll("[data-video-id]");
// We intentionally leave the data-play-* attributes blank in cardHtml and
// assign them after template parsing so the raw URL/magnet strings avoid
@@ -2726,6 +2746,7 @@ class bitvidApp {
// Clear old content, add new
this.videoList.innerHTML = "";
this.videoList.appendChild(fragment);
attachHealthBadges(this.videoList);
// Ensure every thumbnail can recover with a fallback image if the primary
// source fails to load or returns a zero-sized response (some CDNs error
+17
View File
@@ -0,0 +1,17 @@
import { Buffer } from "https://esm.sh/[email protected]?bundle";
const globalScope = typeof globalThis !== "undefined" ? globalThis : window;
if (globalScope && !globalScope.Buffer) {
globalScope.Buffer = Buffer;
}
if (globalScope && !globalScope.global) {
globalScope.global = globalScope;
}
if (globalScope && !globalScope.process) {
globalScope.process = { env: {} };
}
export { Buffer };
+186
View File
@@ -0,0 +1,186 @@
import { infoHashFromMagnet } from "./magnets.js";
import {
getDefaultHealth,
getHealthCached,
queueHealthCheck,
} from "./healthService.js";
const containerState = new WeakMap();
const ROOT_MARGIN = "200px 0px";
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 toVisual(health) {
if (!health) {
return "unknown";
}
if (health.ok && health.seeders > 0) {
return "good";
}
if (health.responded) {
if (health.seeders > 0) {
return "good";
}
return "none";
}
return "noresp";
}
function formatCount(health) {
if (!health || !Number.isFinite(health.seeders) || health.seeders <= 0) {
return "";
}
return ` (${health.seeders})`;
}
function setBadge(card, visual, health) {
const el = card.querySelector(".stream-health");
if (!el) {
return;
}
const map = {
good: {
text: "🟢",
aria: "Streamable: seeders available",
},
none: {
text: "🟡",
aria: "No seeders reported by trackers",
},
noresp: {
text: "⚫",
aria: "No tracker response",
},
checking: {
text: "🟦",
aria: "Checking stream availability",
},
unknown: {
text: "⚪",
aria: "Unknown stream availability",
},
};
const entry = map[visual] || map.unknown;
const countText = formatCount(health);
el.textContent = `${entry.text}${countText}`;
el.setAttribute("aria-label", countText ? `${entry.aria}${countText}` : entry.aria);
el.setAttribute("title", countText ? `${entry.aria}${countText}` : entry.aria);
el.dataset.streamHealthState = visual;
}
function applyHealth(card, health) {
if (!health) {
setBadge(card, "unknown");
return;
}
const visual = toVisual(health);
setBadge(card, visual, health);
}
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;
}
const cached = getHealthCached(infoHash);
if (cached) {
applyHealth(card, cached);
return;
}
if (pendingByCard.has(card)) {
return;
}
setBadge(card, "checking", getDefaultHealth());
const pending = queueHealthCheck(magnet).then((health) => {
pendingByCard.delete(card);
if (!card.isConnected) {
return;
}
applyHealth(card, health);
});
pending.catch(() => {
pendingByCard.delete(card);
if (!card.isConnected) {
return;
}
setBadge(card, "noresp");
});
pendingByCard.set(card, pending);
}
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 });
}
});
}
+98
View File
@@ -0,0 +1,98 @@
import PQueue from "https://esm.sh/[email protected]";
import { trackerPing } from "./trackerPing.js";
import { infoHashFromMagnet } from "./magnets.js";
import { HEALTH_TTL_MS, CONCURRENCY } from "./trackerConfig.js";
const queue = new PQueue({ concurrency: CONCURRENCY });
const cache = new Map();
const inflight = new Map();
export function getDefaultHealth() {
return {
ok: false,
seeders: 0,
leechers: 0,
responded: false,
from: [],
};
}
export function getHealthCached(infoHash) {
if (!infoHash) {
return null;
}
const entry = cache.get(infoHash);
if (!entry) {
return null;
}
if (Date.now() - entry.ts > HEALTH_TTL_MS) {
cache.delete(infoHash);
return null;
}
return entry.value;
}
export function setHealthCache(infoHash, value) {
if (!infoHash) {
return;
}
cache.set(infoHash, { ts: Date.now(), value });
}
export function queueHealthCheck(magnet, onResult) {
const infoHash = infoHashFromMagnet(magnet);
if (!infoHash) {
const fallback = getDefaultHealth();
if (typeof onResult === "function") {
onResult(fallback);
}
return Promise.resolve(fallback);
}
const cached = getHealthCached(infoHash);
if (cached) {
if (typeof onResult === "function") {
onResult(cached);
}
return Promise.resolve(cached);
}
if (inflight.has(infoHash)) {
const pending = inflight.get(infoHash);
if (typeof onResult === "function") {
pending.then(onResult).catch(() => {});
}
return pending;
}
const jobPromise = queue
.add(async () => {
try {
const health = await trackerPing(magnet);
setHealthCache(infoHash, health);
return health;
} catch (err) {
console.warn("trackerPing failed", err);
return getDefaultHealth();
}
})
.finally(() => {
inflight.delete(infoHash);
});
inflight.set(infoHash, jobPromise);
if (typeof onResult === "function") {
jobPromise.then(onResult).catch(() => {});
}
return jobPromise;
}
export function purgeHealthCache() {
const now = Date.now();
Array.from(cache.keys()).forEach((infoHash) => {
const entry = cache.get(infoHash);
if (!entry || now - entry.ts > HEALTH_TTL_MS) {
cache.delete(infoHash);
}
});
}
+1
View File
@@ -1,5 +1,6 @@
// js/index.js
import "./bufferPolyfill.js";
import { trackPageView } from "./analytics.js";
const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in";
+66
View File
@@ -0,0 +1,66 @@
import parseMagnet from "https://esm.sh/[email protected]";
function normalizeInfoHash(candidate) {
const trimmed = typeof candidate === "string" ? candidate.trim() : "";
if (!trimmed) {
return null;
}
if (/^[0-9a-f]{40}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
if (/^[a-z2-7]{32}$/i.test(trimmed)) {
try {
const parsed = parseMagnet(`magnet:?xt=urn:btih:${trimmed}`);
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
return hash ? hash.toLowerCase() : null;
} catch (err) {
console.warn("Failed to normalize base32 info hash", err);
}
}
return null;
}
export function infoHashFromMagnet(magnet) {
if (typeof magnet !== "string") {
return null;
}
const direct = normalizeInfoHash(magnet);
if (direct) {
return direct;
}
try {
const parsed = parseMagnet(magnet);
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
return hash ? hash.toLowerCase() : null;
} catch (err) {
console.warn("Failed to parse magnet for info hash", err);
return null;
}
}
export function trackersFromMagnet(magnet) {
if (typeof magnet !== "string") {
return [];
}
try {
const parsed = parseMagnet(magnet);
if (!parsed || !Array.isArray(parsed.announce)) {
return [];
}
const deduped = new Set();
parsed.announce.forEach((url) => {
if (typeof url !== "string") {
return;
}
const trimmed = url.trim();
if (!trimmed) {
return;
}
deduped.add(trimmed);
});
return Array.from(deduped);
} catch (err) {
console.warn("Failed to parse magnet trackers", err);
return [];
}
}
+28 -2
View File
@@ -3,6 +3,7 @@ import {
nostrClient,
convertEventToVideo as sharedConvertEventToVideo,
} from "./nostr.js";
import { attachHealthBadges } from "./gridHealth.js";
function getAbsoluteShareUrl(nevent) {
if (!nevent) {
@@ -379,9 +380,14 @@ class SubscriptionsManager {
const safeThumb = window.app?.escapeHTML(video.thumbnail) || "";
const playbackUrl =
typeof video.url === "string" ? video.url : "";
const playbackMagnet =
typeof video.magnet === "string" ? video.magnet : "";
const trimmedUrl = playbackUrl ? playbackUrl.trim() : "";
const trimmedMagnet =
typeof video.magnet === "string" ? video.magnet.trim() : "";
const legacyInfoHash =
typeof video.infoHash === "string" ? video.infoHash.trim() : "";
const magnetCandidate = trimmedMagnet || legacyInfoHash;
const playbackMagnet = magnetCandidate;
const magnetProvided = magnetCandidate.length > 0;
const urlStatusHtml = trimmedUrl
? window.app?.getUrlHealthPlaceholderMarkup?.() ?? ""
: "";
@@ -403,6 +409,19 @@ class SubscriptionsManager {
</div>
</a>
<div class="p-4">
<div class="flex items-center justify-between text-xs text-gray-500 mb-2">
<span class="uppercase tracking-wide text-[0.65rem] text-gray-500">
Streamable?
</span>
<span
class="stream-health text-lg"
aria-live="polite"
aria-label="Checking stream availability"
title="Checking stream availability"
>
🟦
</span>
</div>
<h3
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
data-video-id="${video.id}"
@@ -462,6 +481,12 @@ class SubscriptionsManager {
el.dataset.playMagnet = playbackMagnet || "";
});
if (magnetProvided) {
cardEl.dataset.magnet = playbackMagnet;
} else if (cardEl.dataset.magnet) {
delete cardEl.dataset.magnet;
}
if (trimmedUrl && window.app?.handleUrlHealthBadge) {
const badgeEl = cardEl.querySelector("[data-url-health-state]");
if (badgeEl) {
@@ -477,6 +502,7 @@ class SubscriptionsManager {
});
container.appendChild(fragment);
attachHealthBadges(container);
if (window.app) {
window.app.videoList = container;
+39
View File
@@ -0,0 +1,39 @@
import { WSS_TRACKERS as DEFAULT_WSS_TRACKERS } from "./constants.js";
export const TRACKER_TIMEOUT_MS = 3000;
export const TRACKER_PER_MAGNET = 3;
export const HEALTH_TTL_MS = 2 * 60 * 1000;
export const CONCURRENCY = 3;
export const TRACKER_ERROR_COOLDOWN_MS = 60 * 1000;
export function resolveTrackerList({ magnetTrackers } = {}) {
const combined = [];
const seen = new Set();
const pushUnique = (url) => {
if (typeof url !== "string") {
return;
}
const trimmed = url.trim();
if (!trimmed) {
return;
}
if (!/^wss:\/\//i.test(trimmed)) {
return;
}
const normalized = trimmed.toLowerCase();
if (seen.has(normalized)) {
return;
}
seen.add(normalized);
combined.push(trimmed);
};
if (Array.isArray(magnetTrackers)) {
magnetTrackers.forEach(pushUnique);
}
DEFAULT_WSS_TRACKERS.forEach(pushUnique);
return combined.slice(0, TRACKER_PER_MAGNET);
}
+230
View File
@@ -0,0 +1,230 @@
import "./bufferPolyfill.js";
import Client from "https://esm.sh/[email protected]/client?bundle";
import { infoHashFromMagnet, trackersFromMagnet } from "./magnets.js";
import {
resolveTrackerList,
TRACKER_TIMEOUT_MS,
TRACKER_ERROR_COOLDOWN_MS,
} from "./trackerConfig.js";
const trackerState = new Map();
function now() {
return Date.now();
}
function randomPeerId() {
const bytes = new Uint8Array(20);
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256);
}
}
return bytes;
}
function getDefaultHealth() {
return {
ok: false,
seeders: 0,
leechers: 0,
responded: false,
from: [],
};
}
function getTrackerEntry(url) {
const existing = trackerState.get(url);
if (existing) {
return existing;
}
const entry = {
consecutiveErrors: 0,
lastErrorAt: 0,
cooldownUntil: 0,
};
trackerState.set(url, entry);
return entry;
}
function markTrackerSuccess(url) {
const entry = getTrackerEntry(url);
entry.consecutiveErrors = 0;
entry.cooldownUntil = 0;
}
function markTrackerError(url) {
const entry = getTrackerEntry(url);
const nowTs = now();
if (entry.lastErrorAt && nowTs - entry.lastErrorAt < TRACKER_ERROR_COOLDOWN_MS) {
entry.consecutiveErrors += 1;
} else {
entry.consecutiveErrors = 1;
}
entry.lastErrorAt = nowTs;
if (entry.consecutiveErrors >= 2) {
entry.cooldownUntil = nowTs + TRACKER_ERROR_COOLDOWN_MS;
}
}
function isTrackerUsable(url) {
const entry = getTrackerEntry(url);
return entry.cooldownUntil === 0 || entry.cooldownUntil <= now();
}
export async function trackerPing(magnet, trackers) {
const infoHash = infoHashFromMagnet(magnet);
if (!infoHash) {
return getDefaultHealth();
}
const magnetTrackers = trackers || trackersFromMagnet(magnet);
const announceList = resolveTrackerList({ magnetTrackers });
const usable = announceList.filter(isTrackerUsable);
const announces = usable.length ? usable : announceList;
if (!announces.length) {
return getDefaultHealth();
}
const peerId = randomPeerId();
const result = getDefaultHealth();
const clients = new Set();
let settled = false;
let timeoutId = null;
const finalize = () => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
clients.forEach((client) => {
try {
client.destroy();
} catch (err) {
// ignore
}
});
clients.clear();
};
return new Promise((resolve) => {
if (Number.isFinite(TRACKER_TIMEOUT_MS) && TRACKER_TIMEOUT_MS > 0) {
timeoutId = setTimeout(() => {
finalize();
resolve(result);
}, TRACKER_TIMEOUT_MS);
}
let remaining = announces.length;
const handleComplete = (client, url) => {
if (clients.has(client)) {
clients.delete(client);
}
if (url && result.from.includes(url) && result.ok) {
markTrackerSuccess(url);
}
remaining -= 1;
if (remaining <= 0 && !settled) {
finalize();
resolve(result);
}
};
const handleResult = (url, data) => {
result.responded = true;
if (url && !result.from.includes(url)) {
result.from.push(url);
}
const seeders = Number.isFinite(data?.complete)
? Number(data.complete)
: 0;
const leechers = Number.isFinite(data?.incomplete)
? Number(data.incomplete)
: 0;
if (seeders > result.seeders) {
result.seeders = seeders;
}
if (leechers > result.leechers) {
result.leechers = leechers;
}
if (seeders > 0) {
result.ok = true;
}
};
announces.forEach((url) => {
let client;
try {
client = new Client({
infoHash,
peerId,
announce: [url],
});
} catch (err) {
markTrackerError(url);
remaining -= 1;
if (remaining <= 0 && !settled) {
finalize();
resolve(result);
}
return;
}
clients.add(client);
const cleanupAndResolve = () => {
if (settled) {
return;
}
finalize();
resolve(result);
};
client.once("update", (data) => {
handleResult(url, data);
markTrackerSuccess(url);
if (result.ok) {
cleanupAndResolve();
return;
}
handleComplete(client, url);
});
client.once("error", () => {
markTrackerError(url);
handleComplete(client, url);
});
client.once("warning", () => {
handleComplete(client, url);
});
try {
client.start();
} catch (err) {
markTrackerError(url);
handleComplete(client, url);
}
});
if (announces.length === 0) {
finalize();
resolve(result);
}
});
}
export function getTrackerStateSnapshot() {
const snapshot = {};
trackerState.forEach((value, key) => {
snapshot[key] = { ...value };
});
return snapshot;
}