Merge pull request #121 from PR0M3TH3AN/codex/adjust-video-health-check-order

Prioritize viewport-visible cards for health probes
This commit is contained in:
thePR0M3TH3AN
2025-09-27 14:32:10 -04:00
committed by GitHub
2 changed files with 177 additions and 47 deletions

View File

@@ -78,7 +78,9 @@ const PROFILE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
// recovers. Results are stored both in-memory and in localStorage so other
// views can reuse them without re-probing.
const URL_HEALTH_TTL_MS = 45 * 60 * 1000; // 45 minutes
const URL_HEALTH_TIMEOUT_RETRY_MS = 5 * 60 * 1000; // 5 minutes
const URL_PROBE_TIMEOUT_MS = 8 * 1000; // 8 seconds
const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds
const URL_HEALTH_STORAGE_PREFIX = "bitvid:urlHealth:";
const urlHealthCache = new Map();
const urlHealthInFlight = new Map();
@@ -3358,7 +3360,12 @@ class bitvidApp {
};
}
return this.storeUrlHealth(eventId, trimmedUrl, entry);
const ttlOverride =
entry.status === "timeout" || entry.status === "unknown"
? URL_HEALTH_TIMEOUT_RETRY_MS
: undefined;
return this.storeUrlHealth(eventId, trimmedUrl, entry, ttlOverride);
})
.catch((err) => {
console.warn(`[urlHealth] probe failed for ${trimmedUrl}:`, err);
@@ -4507,19 +4514,42 @@ class bitvidApp {
return null;
}
try {
const result = await this.probeUrlWithVideoElement(trimmed);
if (result && result.outcome) {
return result;
const initialTimeout =
Number.isFinite(options?.videoProbeTimeoutMs) &&
options.videoProbeTimeoutMs > 0
? options.videoProbeTimeoutMs
: URL_PROBE_TIMEOUT_MS;
const attemptWithTimeout = async (timeoutMs) => {
try {
const result = await this.probeUrlWithVideoElement(trimmed, timeoutMs);
if (result && result.outcome) {
return result;
}
} catch (err) {
console.warn(
`[probeUrl] Video element probe threw for ${trimmed}:`,
err
);
}
return null;
};
let result = await attemptWithTimeout(initialTimeout);
if (
result &&
result.outcome === "timeout" &&
Number.isFinite(URL_PROBE_TIMEOUT_RETRY_MS) &&
URL_PROBE_TIMEOUT_RETRY_MS > initialTimeout
) {
const retryResult = await attemptWithTimeout(URL_PROBE_TIMEOUT_RETRY_MS);
if (retryResult) {
result = { ...retryResult, retriedAfterTimeout: true };
}
} catch (err) {
console.warn(
`[probeUrl] Video element probe threw for ${trimmed}:`,
err
);
}
return null;
return result;
};
const supportsAbort = typeof AbortController !== "undefined";

View File

@@ -110,7 +110,7 @@ function normalizeResult(result) {
};
}
function queueProbe(magnet, cacheKey) {
function queueProbe(magnet, cacheKey, priority = 0) {
if (!magnet) {
return Promise.resolve(null);
}
@@ -125,21 +125,23 @@ function queueProbe(magnet, 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,
}))
.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,
})),
priority
)
.then((result) => {
const normalized = normalizeResult(result);
@@ -173,13 +175,35 @@ class ProbeQueue {
this.queue = [];
}
run(task) {
run(task, priority = 0) {
return new Promise((resolve, reject) => {
this.queue.push({ task, 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() {
if (this.running >= this.max) {
return;
@@ -233,28 +257,106 @@ function ensureState(container) {
const pendingByCard = new WeakMap();
const observedCards = new WeakSet();
state = { observer: null, pendingByCard, observedCards };
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const card = entry.target;
if (!(card instanceof HTMLElement)) {
return;
}
if (!entry.isIntersecting) {
return;
}
handleCardVisible({ card, pendingByCard });
});
processObserverEntries(entries, state);
},
{ root: null, rootMargin: ROOT_MARGIN, threshold: 0.01 }
);
state = { observer, pendingByCard, observedCards };
state.observer = observer;
containerState.set(container, state);
return state;
}
function getViewportCenter() {
if (typeof window === "undefined") {
return null;
}
const width = Number(window.innerWidth) || 0;
const height = Number(window.innerHeight) || 0;
if (width <= 0 && height <= 0) {
return null;
}
return {
x: width > 0 ? width / 2 : 0,
y: height > 0 ? height / 2 : 0,
};
}
function calculateDistanceSquared(entry, viewportCenter) {
if (!viewportCenter) {
return Number.POSITIVE_INFINITY;
}
const rect = getIntersectionRect(entry);
if (!rect) {
return Number.POSITIVE_INFINITY;
}
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const dx = centerX - viewportCenter.x;
const dy = centerY - viewportCenter.y;
return dx * dx + dy * dy;
}
function computeVisibilityPriority({ ratio, distance }) {
const normalizedRatio = Number.isFinite(ratio)
? Math.min(Math.max(ratio, 0), 1)
: 0;
const distanceScore = Number.isFinite(distance) ? 1 / (1 + distance) : 0;
return normalizedRatio * 1000 + distanceScore;
}
function prioritizeEntries(entries, viewportCenter) {
if (!Array.isArray(entries) || entries.length === 0) {
return [];
}
return entries
.filter((entry) => entry.isIntersecting && entry.target instanceof HTMLElement)
.map((entry) => {
const ratio =
typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
const distance = calculateDistanceSquared(entry, viewportCenter);
const priority = computeVisibilityPriority({ ratio, distance });
return { entry, ratio, distance, priority };
})
.sort((a, b) => {
if (b.priority !== a.priority) {
return b.priority - a.priority;
}
return a.distance - b.distance;
});
}
function processObserverEntries(entries, state) {
if (!state || !entries || entries.length === 0) {
return;
}
const viewportCenter = getViewportCenter();
const prioritized = prioritizeEntries(entries, viewportCenter);
prioritized.forEach(({ entry, priority }) => {
const card = entry.target;
handleCardVisible({ card, pendingByCard: state.pendingByCard, priority });
});
}
function getIntersectionRect(entry) {
if (!entry) {
return null;
}
const rect = entry.intersectionRect;
if (rect && rect.width > 0 && rect.height > 0) {
return rect;
}
const fallback = entry.boundingClientRect;
if (fallback && fallback.width > 0 && fallback.height > 0) {
return fallback;
}
return rect || fallback || null;
}
function setBadge(card, state, details) {
const badge = card.querySelector(".torrent-health-badge");
if (!badge) {
@@ -338,7 +440,7 @@ function setBadge(card, state, details) {
}
}
function handleCardVisible({ card, pendingByCard }) {
function handleCardVisible({ card, pendingByCard, priority = 0 }) {
if (!(card instanceof HTMLElement)) {
return;
}
@@ -371,7 +473,7 @@ function handleCardVisible({ card, pendingByCard }) {
setBadge(card, "checking");
const probePromise = queueProbe(magnet, infoHash);
const probePromise = queueProbe(magnet, infoHash, priority);
pendingByCard.set(card, probePromise);
@@ -424,6 +526,7 @@ export function attachHealthBadges(container) {
setBadge(card, "unknown");
}
});
processObserverEntries(state.observer.takeRecords(), state);
}
export function refreshHealthBadges(container) {
@@ -434,9 +537,6 @@ export function refreshHealthBadges(container) {
if (!state) {
return;
}
state.observer.takeRecords().forEach((entry) => {
if (entry.isIntersecting) {
handleCardVisible({ card: entry.target, pendingByCard: state.pendingByCard });
}
});
const records = state.observer.takeRecords();
processObserverEntries(records, state);
}