mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-04 03:42:26 +00:00
Document v3 publishing schema
This commit is contained in:
1 parent
c488f3fa16
commit
c9ebc80ab4
5 files changed
+269
-45
No files matched your search
@@ -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**
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user