mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add tracker health badges for magnets
This commit is contained in:
1 parent
0ad6d53ebb
commit
ddd8a393f4
10 files changed
+724
-2
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,5 +1,6 @@
|
||||
// js/index.js
|
||||
|
||||
import "./bufferPolyfill.js";
|
||||
import { trackPageView } from "./analytics.js";
|
||||
|
||||
const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in";
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user