Document v3 publishing schema

This commit is contained in:
thePR0M3TH3AN committed 2025-09-25 08:35:19 -04:00
1 parent c488f3fa16
commit c9ebc80ab4
5 files changed
+269 -45

No files matched your search

+11
View File
@@ -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**
+14 -5
View File
@@ -70,13 +70,21 @@
</div>
<!-- Hosted video URL -->
<div class="mb-4">
<label for="uploadUrl" class="block text-sm font-medium">Hosted video URL</label>
<div class="form-group">
<label
for="uploadUrl"
class="block text-sm font-medium text-gray-200"
>Hosted video URL (https)</label
>
<input
id="uploadUrl"
type="url"
class="w-full p-2 rounded"
placeholder="https://cdn.example.com/video.mp4 or .webm/.m3u8"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
placeholder="https://cdn.example.com/video.mp4"
/>
<small class="mt-1 block text-xs text-gray-400"
>Optional but preferred. We will play this first and fall back to
P2P.</small
>
</div>
@@ -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"
/>
<p class="mt-1 text-xs text-gray-400">
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.
</p>
</div>
+218 -31
View File
@@ -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;
}
+13 -6
View File
@@ -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,
+13 -3
View File
@@ -412,7 +412,7 @@ export class TorrentClient {
* Initiates streaming of a torrent magnet to a <video> 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);
});