diff --git a/css/style.css b/css/style.css index 208e5552..bd9cdd74 100644 --- a/css/style.css +++ b/css/style.css @@ -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; diff --git a/js/app.js b/js/app.js index 1a58ee62..2be08ceb 100644 --- a/js/app.js +++ b/js/app.js @@ -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 {
+
+ + Streamable? + + + 🟦 + +

{ + 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 }); + } + }); +} diff --git a/js/healthService.js b/js/healthService.js new file mode 100644 index 00000000..de265bab --- /dev/null +++ b/js/healthService.js @@ -0,0 +1,98 @@ +import PQueue from "https://esm.sh/p-queue@7.4.1"; +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); + } + }); +} diff --git a/js/index.js b/js/index.js index d3c062d0..6ad7302f 100644 --- a/js/index.js +++ b/js/index.js @@ -1,5 +1,6 @@ // js/index.js +import "./bufferPolyfill.js"; import { trackPageView } from "./analytics.js"; const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in"; diff --git a/js/magnets.js b/js/magnets.js new file mode 100644 index 00000000..b5ab9fd3 --- /dev/null +++ b/js/magnets.js @@ -0,0 +1,66 @@ +import parseMagnet from "https://esm.sh/magnet-uri@9.1.2"; + +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 []; + } +} diff --git a/js/subscriptions.js b/js/subscriptions.js index 2ef66b04..50ed39cb 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -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 {

+
+ + Streamable? + + + 🟦 + +

{ + 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); +} diff --git a/js/trackerPing.js b/js/trackerPing.js new file mode 100644 index 00000000..eb85c6ba --- /dev/null +++ b/js/trackerPing.js @@ -0,0 +1,230 @@ +import "./bufferPolyfill.js"; +import Client from "https://esm.sh/bittorrent-tracker@11.0.0/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; +}