import { SHORT_TIMEOUT_MS } from "../constants.js"; import { isHlsUrl, prefersNativeHls, probeHlsManifest, } from "../services/hlsPlayback.js"; export default class UrlHealthController { constructor({ state = {}, utils = {}, logger = {}, constants = {}, callbacks = {}, } = {}) { this.state = state; this.utils = utils; this.logger = logger; this.constants = constants; this.callbacks = callbacks; this.activeVideoProbes = 0; this.videoProbeQueue = []; this.MAX_CONCURRENT_VIDEO_PROBES = 3; } getUrlHealthPlaceholderMarkup(options = {}) { const includeMargin = options?.includeMargin !== false; const classes = ["badge", "url-health-badge", "text-muted"]; if (includeMargin) { classes.push("mt-sm"); } return ` ⏳ CDN `; } updateUrlHealthBadge(badgeEl, state, videoId) { if (!badgeEl) { return; } if ( videoId && badgeEl.dataset.urlHealthFor && badgeEl.dataset.urlHealthFor !== videoId ) { return; } if (!badgeEl.isConnected) { if (typeof requestAnimationFrame === "function") { requestAnimationFrame(() => { if (badgeEl.isConnected) { this.updateUrlHealthBadge(badgeEl, state, videoId); } }); } return; } const status = state?.status || "checking"; const fallbackMessages = { healthy: "✅ CDN", offline: "❌ CDN", unknown: "⚠️ CDN", timeout: "⚠️ CDN timed out", checking: "⏳ CDN", }; const message = state?.message || fallbackMessages[status] || fallbackMessages.checking; const hadCompactMargin = badgeEl.classList.contains("mt-sm") || badgeEl.classList.contains("mt-3"); badgeEl.dataset.urlHealthState = status; const cardEl = badgeEl.closest(".card[data-video-id]"); if (cardEl && typeof this.utils.updateVideoCardSourceVisibility === "function") { cardEl.dataset.urlHealthState = status; this.utils.updateVideoCardSourceVisibility(cardEl); } badgeEl.setAttribute("aria-live", "polite"); badgeEl.setAttribute("role", status === "offline" ? "alert" : "status"); badgeEl.textContent = message; const classes = ["badge", "url-health-badge"]; if (hadCompactMargin) { classes.push("mt-sm"); } badgeEl.className = classes.join(" "); const variantMap = { healthy: "success", offline: "critical", unknown: "neutral", timeout: "neutral", checking: "neutral", }; const variant = variantMap[status]; if (variant) { badgeEl.dataset.variant = variant; } else if (badgeEl.dataset.variant) { delete badgeEl.dataset.variant; } const videoListView = this.callbacks.getVideoListView && typeof this.callbacks.getVideoListView === "function" ? this.callbacks.getVideoListView() : null; if ( videoId && videoListView && typeof videoListView.cacheUrlHealth === "function" ) { videoListView.cacheUrlHealth(videoId, { status, message, lastCheckedAt: Number.isFinite(state?.lastCheckedAt) ? state.lastCheckedAt : undefined, }); } } handleUrlHealthBadge({ video, url, badgeEl }) { if (!video?.id || !badgeEl || !url) { return; } const eventId = video.id; const trimmedUrl = typeof url === "string" ? url.trim() : ""; if (!trimmedUrl) { return; } badgeEl.dataset.urlHealthFor = eventId; if (typeof this.state.getCachedUrlHealth === "function") { const cached = this.state.getCachedUrlHealth(eventId, trimmedUrl); if (cached) { this.updateUrlHealthBadge(badgeEl, cached, eventId); return; } } this.updateUrlHealthBadge(badgeEl, { status: "checking" }, eventId); // Try the primary url plus any additional NIP-71 source mirrors, so one dead // host doesn't mark a video offline when an alternate plays. const sources = Array.isArray(video?.sources) ? video.sources : []; const candidateUrls = []; const seenUrls = new Set(); for (const candidate of [ trimmedUrl, ...sources.map((s) => (s && typeof s.url === "string" ? s.url.trim() : "")), ]) { if (candidate && !seenUrls.has(candidate)) { seenUrls.add(candidate); candidateUrls.push(candidate); } } const probeOptions = { confirmPlayable: true }; const getInFlight = this.state.getInFlightUrlProbe; const existingProbe = typeof getInFlight === "function" ? getInFlight(eventId, trimmedUrl, probeOptions) : null; if (existingProbe) { existingProbe .then((entry) => { if (entry) { this.updateUrlHealthBadge(badgeEl, entry, eventId); } }) .catch((err) => { this.logger.warn( `[urlHealth] cached probe promise rejected for ${trimmedUrl}:`, err ); }); return; } const probePromise = this.probeUrlList(candidateUrls, probeOptions) .then((result) => { const outcome = result?.outcome || "error"; let entry; if (outcome === "ok") { entry = { status: "healthy", message: "✅ CDN" }; } else if (outcome === "opaque" || outcome === "unknown") { entry = { status: "unknown", message: "⚠️ CDN", }; } else if (outcome === "timeout") { entry = { status: "timeout", message: "⚠️ CDN timed out", }; } else { entry = { status: "offline", message: "❌ CDN", }; } const urlHealthConstants = this.constants.urlHealthConstants || {}; const ttlOverride = entry.status === "timeout" || entry.status === "unknown" ? urlHealthConstants.URL_HEALTH_TIMEOUT_RETRY_MS : undefined; if (typeof this.state.storeUrlHealth === "function") { return this.state.storeUrlHealth( eventId, trimmedUrl, entry, ttlOverride ); } return entry; }) .catch((err) => { this.logger.warn(`[urlHealth] probe failed for ${trimmedUrl}:`, err); const entry = { status: "offline", message: "❌ CDN", }; if (typeof this.state.storeUrlHealth === "function") { return this.state.storeUrlHealth(eventId, trimmedUrl, entry); } return entry; }); if (typeof this.state.setInFlightUrlProbe === "function") { this.state.setInFlightUrlProbe( eventId, trimmedUrl, probePromise, probeOptions ); } probePromise .then((entry) => { if (entry) { this.updateUrlHealthBadge(badgeEl, entry, eventId); } }) .catch((err) => { this.logger.warn( `[urlHealth] probe promise rejected post-cache for ${trimmedUrl}:`, err ); }); } async probeUrlWithVideoElement(url, timeoutMs) { const defaultTimeout = this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS; const effectiveTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : defaultTimeout; const trimmed = typeof url === "string" ? url.trim() : ""; if (!trimmed || typeof document === "undefined") { return { outcome: "error" }; } // A bare