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 @@
-
-
Hosted video URL
+
+ Hosted video URL (https)
+ 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 element.
* Ensures the service worker is set up only once and the client is reused.
*/
- async streamVideo(magnetURI, videoElement) {
+ async streamVideo(magnetURI, videoElement, opts = {}) {
try {
// 1) Make sure we have a WebTorrent client and a valid SW registration.
await this.init();
@@ -428,6 +428,16 @@ export class TorrentClient {
}
const isFirefoxBrowser = this.isFirefox();
+ const candidateUrls = Array.isArray(opts?.urlList)
+ ? opts.urlList
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
+ .filter((entry) => /^https?:\/\//i.test(entry))
+ : [];
+
+ const chromeOptions = { strategy: "sequential" };
+ if (candidateUrls.length) {
+ chromeOptions.urlList = candidateUrls;
+ }
return new Promise((resolve, reject) => {
// 3) Add the torrent to the client and handle accordingly.
@@ -435,7 +445,7 @@ export class TorrentClient {
this.log("Starting torrent download (Firefox path)");
this.client.add(
magnetURI,
- { strategy: "sequential", maxWebConns: 4 },
+ { ...chromeOptions, maxWebConns: 4 },
(torrent) => {
this.log("Torrent added (Firefox path):", torrent.name);
this.handleFirefoxTorrent(torrent, videoElement, resolve, reject);
@@ -443,7 +453,7 @@ export class TorrentClient {
);
} else {
this.log("Starting torrent download (Chrome path)");
- this.client.add(magnetURI, { strategy: "sequential" }, (torrent) => {
+ this.client.add(magnetURI, chromeOptions, (torrent) => {
this.log("Torrent added (Chrome path):", torrent.name);
this.handleChromeTorrent(torrent, videoElement, resolve, reject);
});