import { getUrlHealthBadgeOwner } from "../urlHealthObserver.js";
import { SHORT_TIMEOUT_MS } from "../constants.js";
import {
isHlsUrl,
prefersNativeHls,
probeHlsManifest
} from "../services/hlsPlayback.js";
// Inflight cache is shared, so ownership also spans controller instances.
const urlProbeRecords = new WeakMap();
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,
owner = getUrlHealthBadgeOwner(badgeEl)
) {
if (owner && !owner.active) return;
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, owner);
}
});
}
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 owner = getUrlHealthBadgeOwner(badgeEl) || { active: true };
if (!owner.active) 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);
if (!owner.active) return;
// 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;
const existingRecord = existingProbe && urlProbeRecords.get(existingProbe);
if (existingProbe && !existingRecord?.cancelled) {
const release = existingRecord
? this.retainUrlProbe(existingRecord, owner)
: () => {};
existingProbe
.then((entry) => {
release();
if (entry && owner.active) {
this.updateUrlHealthBadge(badgeEl, entry, eventId, owner);
}
})
.catch((err) => {
release();
this.logger.warn(
`[urlHealth] cached probe promise rejected for ${trimmedUrl}:`,
err
);
});
return;
}
const record = { owners: new Set(), cancelled: false, cancelQueued: null };
const release = this.retainUrlProbe(record, owner);
const probePromise = this.probeUrlList(candidateUrls, {
...probeOptions,
lifecycle: record
})
.then((result) => {
if (record.cancelled || result?.outcome === "cancelled") return null;
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) => {
if (record.cancelled) return null;
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;
});
urlProbeRecords.set(probePromise, record);
if (typeof this.state.setInFlightUrlProbe === "function") {
this.state.setInFlightUrlProbe(
eventId,
trimmedUrl,
probePromise,
probeOptions
);
}
probePromise
.then((entry) => {
release();
if (entry && owner.active) {
this.updateUrlHealthBadge(badgeEl, entry, eventId, owner);
}
})
.catch((err) => {
release();
this.logger.warn(
`[urlHealth] probe promise rejected post-cache for ${trimmedUrl}:`,
err
);
});
}
retainUrlProbe(record, owner) {
record.owners.add(owner);
const onDispose = () => {
record.owners.delete(owner);
if (!record.owners.size) record.cancelQueued?.();
};
owner.releases?.add(onDispose);
return () => {
record.owners.delete(owner);
owner.releases?.delete(onDispose);
};
}
async probeUrlWithVideoElement(url, timeoutMs, lifecycle) {
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