diff --git a/js/app.js b/js/app.js index eb0e3dae..7bfbab96 100644 --- a/js/app.js +++ b/js/app.js @@ -26,6 +26,100 @@ const UNSUPPORTED_BTITH_MESSAGE = "This magnet link is missing a compatible BitTorrent v1 info hash."; const FALLBACK_THUMBNAIL_SRC = "assets/jpg/video-thumbnail-fallback.jpg"; +// We probe hosted URLs often enough that a naive implementation would spam +// remote CDNs. A tiny, short-lived cache keeps us from hammering dead hosts +// while still giving the UI near-real-time feedback when a URL comes back. +const URL_HEALTH_TTL_MS = 45_000; +const urlHealthCache = new Map(); +const urlHealthInFlight = new Map(); + +/** + * Returns the cached health result for a given event ID when the entry is + * still "fresh". Entries are evicted eagerly once their TTL expires or when + * the associated URL changes (e.g. a user edits the post to point elsewhere). + */ +function readUrlHealthFromCache(eventId, url) { + if (!eventId) { + return null; + } + + const entry = urlHealthCache.get(eventId); + if (!entry) { + return null; + } + + const now = Date.now(); + if (entry.expiresAt <= now) { + urlHealthCache.delete(eventId); + return null; + } + + if (url && entry.url && entry.url !== url) { + urlHealthCache.delete(eventId); + return null; + } + + return entry; +} + +/** + * Stores a health result and calculates the expiry moment. The TTL is short on + * purpose (~45s) so we recover quickly when a CDN comes back online while + * still avoiding redundant probes during render thrash. + */ +function writeUrlHealthToCache(eventId, url, result, ttlMs = URL_HEALTH_TTL_MS) { + if (!eventId) { + return null; + } + + const ttl = typeof ttlMs === "number" && ttlMs > 0 ? ttlMs : URL_HEALTH_TTL_MS; + const now = Date.now(); + const entry = { + status: result?.status || "checking", + message: result?.message || "Checking hosted URL…", + url: url || result?.url || "", + expiresAt: now + ttl, + lastCheckedAt: now, + }; + urlHealthCache.set(eventId, entry); + return entry; +} + +/** + * Tracks in-flight probe promises so that multiple cards rendering the same + * event simultaneously do not all fire off duplicate requests. The stored + * metadata includes the URL so edits invalidate the old request immediately. + */ +function setInFlightUrlProbe(eventId, url, promise) { + if (!eventId || !promise) { + return; + } + + urlHealthInFlight.set(eventId, { promise, url }); + promise.finally(() => { + const current = urlHealthInFlight.get(eventId); + if (current && current.promise === promise) { + urlHealthInFlight.delete(eventId); + } + }); +} + +function getInFlightUrlProbe(eventId, url) { + if (!eventId) { + return null; + } + + const entry = urlHealthInFlight.get(eventId); + if (!entry) { + return null; + } + + if (url && entry.url && entry.url !== url) { + return null; + } + + return entry.promise; +} // NOTE: The modal uses a "please stand by" animated poster while the torrent // or direct URL boots up. We've regressed multiple times by forgetting to clear // that poster once playback starts, which leaves the loading GIF covering the @@ -1544,6 +1638,151 @@ class bitvidApp { return olderMatches.length > 0; } + /** + * Centralised helper for other modules (channel profiles, subscriptions) + * so they can re-use the exact same badge skeleton. Keeping the markup in + * one place avoids subtle mismatches when we tweak copy or classes later. + */ + getUrlHealthPlaceholderMarkup() { + return ` +
+ Checking hosted URL… +
+ `; + } + + getCachedUrlHealth(eventId, url) { + return readUrlHealthFromCache(eventId, url); + } + + storeUrlHealth(eventId, url, result, ttlMs) { + return writeUrlHealthToCache(eventId, url, result, ttlMs); + } + + updateUrlHealthBadge(badgeEl, state, videoId) { + if (!badgeEl || !badgeEl.isConnected) { + return; + } + + if (videoId && badgeEl.dataset.urlHealthFor && badgeEl.dataset.urlHealthFor !== videoId) { + return; + } + + const status = state?.status || "checking"; + const message = state?.message || + (status === "healthy" + ? "CDN Healthy" + : status === "offline" + ? "URL offline — using P2P fallback" + : "Checking hosted URL…"); + + badgeEl.dataset.urlHealthState = status; + badgeEl.setAttribute("aria-live", "polite"); + badgeEl.setAttribute("role", status === "offline" ? "alert" : "status"); + badgeEl.textContent = message; + + badgeEl.className = + "url-health-badge mt-3 text-xs font-semibold px-2 py-1 rounded transition-colors duration-200"; + + if (status === "healthy") { + badgeEl.classList.add( + "inline-flex", + "items-center", + "gap-1", + "bg-green-900", + "text-green-200" + ); + } else if (status === "offline") { + badgeEl.classList.add("block", "bg-red-900", "text-red-200"); + } else { + badgeEl.classList.add( + "inline-flex", + "items-center", + "gap-1", + "bg-gray-800", + "text-gray-300" + ); + } + } + + 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; + + const cached = this.getCachedUrlHealth(eventId, trimmedUrl); + if (cached) { + this.updateUrlHealthBadge(badgeEl, cached, eventId); + return; + } + + this.updateUrlHealthBadge(badgeEl, { status: "checking" }, eventId); + + const existingProbe = getInFlightUrlProbe(eventId, trimmedUrl); + if (existingProbe) { + existingProbe + .then((entry) => { + if (entry) { + this.updateUrlHealthBadge(badgeEl, entry, eventId); + } + }) + .catch((err) => { + console.warn( + `[urlHealth] cached probe promise rejected for ${trimmedUrl}:`, + err + ); + }); + return; + } + + const probePromise = this.probeUrl(trimmedUrl) + .then((ok) => { + const result = ok + ? { status: "healthy", message: "CDN Healthy" } + : { + status: "offline", + message: "URL offline — using P2P fallback", + }; + return this.storeUrlHealth(eventId, trimmedUrl, result); + }) + .catch((err) => { + console.warn(`[urlHealth] probe failed for ${trimmedUrl}:`, err); + const result = { + status: "offline", + message: "URL offline — using P2P fallback", + }; + return this.storeUrlHealth(eventId, trimmedUrl, result); + }); + + setInFlightUrlProbe(eventId, trimmedUrl, probePromise); + + probePromise + .then((entry) => { + if (entry) { + this.updateUrlHealthBadge(badgeEl, entry, eventId); + } + }) + .catch((err) => { + console.warn( + `[urlHealth] probe promise rejected post-cache for ${trimmedUrl}:`, + err + ); + }); + } + async renderVideoList(videos) { if (!this.videoList) return; @@ -1662,6 +1901,10 @@ class bitvidApp { ` : ""; + const urlStatusHtml = trimmedUrl + ? this.getUrlHealthPlaceholderMarkup() + : ""; + const cardHtml = `
@@ -1718,6 +1961,7 @@ class bitvidApp {
${gearMenu} + ${urlStatusHtml} ${torrentBadge} @@ -1748,6 +1992,17 @@ class bitvidApp { el.dataset.torrentSupported = magnetSupported ? "true" : "false"; } }); + + if (trimmedUrl) { + const badgeEl = cardEl.querySelector("[data-url-health-state]"); + if (badgeEl) { + this.handleUrlHealthBadge({ + video, + url: trimmedUrl, + badgeEl, + }); + } + } } fragment.appendChild(cardEl); }); diff --git a/js/channelProfile.js b/js/channelProfile.js index 833c1262..0ad2eaf0 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -345,7 +345,11 @@ async function loadUserVideos(pubkey) { const magnetCandidate = trimmedMagnet || legacyInfoHash; const playbackUrl = typeof video.url === "string" ? video.url : ""; + const trimmedUrl = playbackUrl ? playbackUrl.trim() : ""; const playbackMagnet = magnetCandidate || ""; + const urlStatusHtml = trimmedUrl + ? app.getUrlHealthPlaceholderMarkup() + : ""; cardEl.innerHTML = `
-
-
-

- ${safeTitle} -

-

- ${new Date(video.created_at * 1000).toLocaleString()} -

+
+
+
+

+ ${safeTitle} +

+

+ ${new Date(video.created_at * 1000).toLocaleString()} +

+
+ ${gearMenu}
- ${gearMenu} + ${urlStatusHtml}
`; @@ -393,6 +400,17 @@ async function loadUserVideos(pubkey) { el.dataset.playMagnet = playbackMagnet || ""; }); + if (trimmedUrl) { + const badgeEl = cardEl.querySelector("[data-url-health-state]"); + if (badgeEl) { + app.handleUrlHealthBadge({ + video, + url: trimmedUrl, + badgeEl, + }); + } + } + fragment.appendChild(cardEl); }); diff --git a/js/subscriptions.js b/js/subscriptions.js index be192aee..61031c5d 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -348,6 +348,10 @@ class SubscriptionsManager { typeof video.url === "string" ? video.url : ""; const playbackMagnet = typeof video.magnet === "string" ? video.magnet : ""; + const trimmedUrl = playbackUrl ? playbackUrl.trim() : ""; + const urlStatusHtml = trimmedUrl + ? window.app?.getUrlHealthPlaceholderMarkup?.() ?? "" + : ""; const cardHtml = ` + ${urlStatusHtml}
`; @@ -417,6 +422,17 @@ class SubscriptionsManager { el.dataset.playUrl = encodeURIComponent(playbackUrl || ""); el.dataset.playMagnet = playbackMagnet || ""; }); + + if (trimmedUrl && window.app?.handleUrlHealthBadge) { + const badgeEl = cardEl.querySelector("[data-url-health-state]"); + if (badgeEl) { + window.app.handleUrlHealthBadge({ + video, + url: trimmedUrl, + badgeEl, + }); + } + } } fragment.appendChild(cardEl); });