From c9ebc80ab462afcf3ab6c576346db77b52509c41 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 25 Sep 2025 08:35:19 -0400 Subject: [PATCH] Document v3 publishing schema --- AGENTS.md | 11 ++ components/upload-modal.html | 19 ++- js/app.js | 249 ++++++++++++++++++++++++++++++----- js/nostr.js | 19 ++- js/webtorrent.js | 16 ++- 5 files changed, 269 insertions(+), 45 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f658d0c5..1c211c1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,4 +70,15 @@ Document the run in PR descriptions so QA can cross-reference results. * **Probing:** Lightweight `HEAD`/`GET` requests should back `probeUrl()` so dead URLs can be hidden or flagged without blocking the UI. * **Extensibility:** Future work (live streams, NIP-96 uploads, analytics) should preserve the URL-first strategy and magnet safety rules above. +--- + +## 7. Content Schema v3 & Playback Rules + +* **Event payloads:** New video notes serialize as version `3` with the JSON shape: + `{ "version": 3, "title": string, "url"?: string, "magnet"?: string, "thumbnail"?: string, "description"?: string, "mode": "live"|"dev", "isPrivate": boolean, "deleted": boolean, "videoRootId": string }`. +* **Validation:** Every note must include a non-empty `title` plus at least one playable source (`url` or `magnet`). URL-only and magnet-only posts are both valid. Legacy v2 magnet notes stay readable. +* **Upload UX:** The modal collects a hosted HTTPS URL and/or magnet. Enforce HTTPS for direct playback while keeping magnets optional when a URL is supplied. +* **Playback orchestration:** `playVideoWithFallback` probes and plays the HTTPS URL first, watches for stalls/errors, and then falls back to WebTorrent. When both sources exist, pass the hosted URL through to WebTorrent as a webseed hint. +* **Status messaging:** Update modal copy to reflect whether playback is direct or via P2P so regressions surface quickly during QA. + **End of AGENTS.md** diff --git a/components/upload-modal.html b/components/upload-modal.html index 59124b05..ea0eeb15 100644 --- a/components/upload-modal.html +++ b/components/upload-modal.html @@ -70,13 +70,21 @@ -
- +
+ + Optional but preferred. We will play this first and fall back to + P2P.
@@ -109,7 +117,8 @@ class="mt-1 block w-full rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500" />

- Include this when you have a torrent source. Bitvid will automatically augment it with any web seeds or metadata you provide. + Provide a hosted URL for instant playback. Add a magnet for P2P + cost control.

diff --git a/js/app.js b/js/app.js index 50a8c0ab..6202f110 100644 --- a/js/app.js +++ b/js/app.js @@ -414,6 +414,7 @@ class bitvidApp { // Lazy-loading helper for images this.mediaLoader = new MediaLoader(); this.activeIntervals = []; + this.urlPlaybackWatchdogCleanup = null; // Optional: a "profile" button or avatar (if used) this.profileButton = document.getElementById("profileButton") || null; @@ -1266,7 +1267,7 @@ class bitvidApp { const description = descEl?.value.trim() || ""; const formData = { - version: 2, + version: 3, title, url, magnet, @@ -1277,16 +1278,21 @@ class bitvidApp { }; if (!formData.title || (!formData.url && !formData.magnet)) { - this.showError( - "Please add a title plus either a hosted video URL or a magnet link." - ); + this.showError("Title and at least one of URL or Magnet is required."); return; } - formData.magnet = normalizeAndAugmentMagnet(formData.magnet, { - ws, - xs, - }); + if (formData.url && !/^https:\/\//i.test(formData.url)) { + this.showError("Hosted video URLs must use HTTPS."); + return; + } + + if (formData.magnet) { + formData.magnet = normalizeAndAugmentMagnet(formData.magnet, { + ws, + xs, + }); + } try { await nostrClient.publishVideo(formData, this.pubkey); @@ -1415,6 +1421,7 @@ class bitvidApp { async cleanup({ preserveSubscriptions = false, preserveObservers = false } = {}) { try { this.clearActiveIntervals(); + this.cleanupUrlPlaybackWatchdog(); if (!preserveObservers && this.mediaLoader) { this.mediaLoader.disconnect(); @@ -1467,6 +1474,113 @@ class bitvidApp { this.activeIntervals = []; } + cleanupUrlPlaybackWatchdog() { + if (typeof this.urlPlaybackWatchdogCleanup === "function") { + try { + this.urlPlaybackWatchdogCleanup(); + } catch (err) { + console.warn("[cleanupUrlPlaybackWatchdog]", err); + } finally { + this.urlPlaybackWatchdogCleanup = null; + } + } + } + + registerUrlPlaybackWatchdogs( + videoElement, + { stallMs = 8000, onSuccess, onFallback } = {} + ) { + this.cleanupUrlPlaybackWatchdog(); + + if (!videoElement || typeof onFallback !== "function") { + return () => {}; + } + + const normalizedStallMs = Number.isFinite(stallMs) && stallMs > 0 ? stallMs : 0; + let active = true; + let stallTimerId = null; + + const listeners = []; + + const cleanup = () => { + if (!active) { + return; + } + active = false; + if (stallTimerId) { + clearTimeout(stallTimerId); + stallTimerId = null; + } + for (const [eventName, handler] of listeners) { + videoElement.removeEventListener(eventName, handler); + } + this.urlPlaybackWatchdogCleanup = null; + }; + + const triggerFallback = (reason) => { + if (!active) { + return; + } + cleanup(); + onFallback(reason); + }; + + const handleSuccess = () => { + if (!active) { + return; + } + cleanup(); + if (typeof onSuccess === "function") { + onSuccess(); + } + }; + + const resetTimer = () => { + if (!active || !normalizedStallMs) { + return; + } + if (stallTimerId) { + clearTimeout(stallTimerId); + } + stallTimerId = setTimeout(() => triggerFallback("stall"), normalizedStallMs); + }; + + const addListener = (eventName, handler) => { + videoElement.addEventListener(eventName, handler); + listeners.push([eventName, handler]); + }; + + addListener("error", () => triggerFallback("error")); + addListener("abort", () => triggerFallback("abort")); + addListener("stalled", () => triggerFallback("stalled")); + addListener("emptied", () => triggerFallback("emptied")); + addListener("playing", handleSuccess); + addListener("ended", handleSuccess); + + const timerEvents = [ + "timeupdate", + "progress", + "loadeddata", + "canplay", + "canplaythrough", + "suspend", + "waiting", + ]; + for (const eventName of timerEvents) { + addListener(eventName, resetTimer); + } + + if (normalizedStallMs) { + resetTimer(); + } + + this.urlPlaybackWatchdogCleanup = () => { + cleanup(); + }; + + return this.urlPlaybackWatchdogCleanup; + } + resetTorrentStats() { if (this.modalPeers) { this.modalPeers.textContent = ""; @@ -2573,7 +2687,18 @@ class bitvidApp { } } - async playViaWebTorrent(magnet, { fallbackMagnet = "" } = {}) { + async playViaWebTorrent( + magnet, + { fallbackMagnet = "", urlList = [] } = {} + ) { + const sanitizedUrlList = Array.isArray(urlList) + ? urlList + .map((entry) => + typeof entry === "string" ? entry.trim() : "" + ) + .filter((entry) => /^https?:\/\//i.test(entry)) + : []; + const attemptStream = async (candidate) => { const trimmedCandidate = typeof candidate === "string" ? candidate.trim() : ""; @@ -2616,7 +2741,8 @@ class bitvidApp { const torrentInstance = await torrentClient.streamVideo( cacheBustedMagnet, - this.modalVideo + this.modalVideo, + { urlList: sanitizedUrlList } ); if (torrentInstance && torrentInstance.ready) { // Some browsers delay `playing` events for MediaSource-backed torrents. @@ -2717,6 +2843,7 @@ class bitvidApp { this.resetTorrentStats(); this.playSource = null; + this.cleanupUrlPlaybackWatchdog(); if (magnetForPlayback) { const label = playbackConfig.didMutate @@ -2725,38 +2852,98 @@ class bitvidApp { this.log(`${label} ${magnetForPlayback}`); } - if (URL_FIRST_ENABLED && sanitizedUrl) { - if (this.modalStatus) { - this.modalStatus.textContent = "Checking hosted URL..."; - } - const urlPlayable = await this.probeUrl(sanitizedUrl); + const httpsUrl = + sanitizedUrl && /^https:\/\//i.test(sanitizedUrl) ? sanitizedUrl : ""; + const webSeedCandidates = httpsUrl ? [httpsUrl] : []; - if (urlPlayable) { + const startTorrentFallback = async (reason) => { + this.cleanupUrlPlaybackWatchdog(); + if (!magnetForPlayback) { + const message = + "Hosted playback failed and no magnet fallback is available."; if (this.modalStatus) { - this.modalStatus.textContent = "Streaming from URL"; + this.modalStatus.textContent = message; } - const played = await this.playHttp(videoEl, sanitizedUrl); - if (played) { - this.forceRemoveModalPoster("http-success"); - this.playSource = "url"; - return; - } - this.log( - "[playVideoWithFallback] Direct URL playback promise rejected." - ); + this.playSource = null; + throw new Error(message); } - } - if (magnetForPlayback) { if (this.modalStatus) { this.modalStatus.textContent = "Switching to WebTorrent..."; } + this.log( + `[playVideoWithFallback] Falling back to WebTorrent (${reason}).` + ); console.debug("[WT] add magnet =", magnetForPlayback); - await this.playViaWebTorrent(magnetForPlayback, { - fallbackMagnet, - }); + const torrentInstance = await this.playViaWebTorrent( + magnetForPlayback, + { + fallbackMagnet, + urlList: webSeedCandidates, + } + ); this.playSource = "torrent"; this.autoplayModalVideo(); + return torrentInstance; + }; + + if (URL_FIRST_ENABLED && httpsUrl) { + if (this.modalStatus) { + this.modalStatus.textContent = "Checking hosted URL..."; + } + const urlPlayable = await this.probeUrl(httpsUrl); + + if (urlPlayable) { + let outcomeResolver; + const playbackOutcomePromise = new Promise((resolve) => { + outcomeResolver = resolve; + this.registerUrlPlaybackWatchdogs(videoEl, { + stallMs: 8000, + onSuccess: () => resolve({ status: "success" }), + onFallback: (reason) => resolve({ status: "fallback", reason }), + }); + }); + + try { + videoEl.src = httpsUrl; + const playPromise = videoEl.play(); + if (playPromise && typeof playPromise.then === "function") { + await playPromise; + } + } catch (err) { + this.log( + "[playVideoWithFallback] Direct URL playback threw:", + err + ); + if (typeof outcomeResolver === "function") { + outcomeResolver({ status: "fallback", reason: "play-error" }); + } + return await startTorrentFallback("play-error"); + } + + const playbackOutcome = await playbackOutcomePromise; + if (playbackOutcome?.status === "success") { + this.forceRemoveModalPoster("http-success"); + this.playSource = "url"; + if (this.modalStatus) { + this.modalStatus.textContent = "Streaming from URL"; + } + return; + } + + const fallbackReason = + playbackOutcome?.reason || "watchdog-triggered"; + await startTorrentFallback(fallbackReason); + return; + } + + this.log( + "[playVideoWithFallback] Hosted URL probe failed; deferring to WebTorrent." + ); + } + + if (magnetForPlayback) { + await startTorrentFallback("magnet-primary"); return; } diff --git a/js/nostr.js b/js/nostr.js index c846f278..f690723a 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -263,10 +263,18 @@ function convertEventToVideo(event = {}) { return { id: event.id, invalid: true, reason }; } - const rawVersion = parsedContent.version ?? 1; - let version = Number(rawVersion); + const rawVersion = parsedContent.version; + let version = rawVersion === undefined ? 2 : Number(rawVersion); if (!Number.isFinite(version)) { - version = 1; + version = rawVersion === undefined ? 2 : 1; + } + + if (version < 2 && !ACCEPT_LEGACY_V1) { + return { + id: event.id, + invalid: true, + reason: `unsupported version ${version}`, + }; } return { @@ -417,8 +425,7 @@ class NostrClient { } /** - * Publish a new video - * CHANGED: Force version=2 for all new notes + * Publish a new video using the v3 content schema. */ async publishVideo(videoData, pubkey) { if (!pubkey) throw new Error("Not logged in to publish video."); @@ -454,7 +461,7 @@ class NostrClient { const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const contentObject = { - version: 2, // forcibly set version=2 + version: 3, title: finalTitle, url: finalUrl, magnet: finalMagnet, diff --git a/js/webtorrent.js b/js/webtorrent.js index 77b792f2..90b272fc 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -412,7 +412,7 @@ export class TorrentClient { * Initiates streaming of a torrent magnet to a