From d23cfa6697ac161ff1f69ee33b400aa5acfb82e3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:08:53 -0400 Subject: [PATCH] feat(blossom): P2P for Blossom videos via a WebTorrent-metadata companion event (#30) Blossom servers reject a .torrent (415), so a Blossom video's magnet is webseed-only and can't bootstrap WebTorrent. This restores P2P without hosting a .torrent: - Upload hardening: correct Content-Type (overrides empty/generic application/octet-stream), HTTP status surfaced in errors, and best-effort .torrent sidecar so a rejected sidecar no longer sinks the upload (URL-first fallback). - Publish: when the .torrent can't be hosted, publish the piece-map as an infohash-keyed companion event (kind 30078, d=bitvid:torrent:) and ship a ws= magnet. Gated by FEATURE_BLOSSOM_TORRENT_METADATA (default off = no trace); size-capped. - Playback: torrentMetadataService lazily fetches the companion, VERIFIES the reconstructed infohash === the magnet's btih (untrusted content), sources the webseed from the video event, and hands the .torrent buffer to WebTorrent so it streams with zero peers. Relay reads go through the subscription manager. Inherited by any upload-based media (shorts, audio-publish); excludes ingest/live. See docs/blossom-torrent-metadata-plan.md. Includes a Prettier reflow in css/tailwind.source.css. Co-Authored-By: Claude Opus 4.8 --- config/instance-config.js | 17 ++ css/tailwind.source.css | 6 +- docs/blossom-torrent-metadata-plan.md | 277 ++++++++++++++++++++++++ docs/nostr-event-schemas.md | 1 + js/app.js | 7 + js/app/playbackCoordinator.js | 50 +++++ js/config.js | 2 + js/constants.js | 18 ++ js/nostrEventSchemas.js | 82 +++++++ js/services/blossomService.js | 221 +++++++++++++++++-- js/services/torrentMetadataService.js | 164 ++++++++++++++ js/ui/components/EditModal.js | 2 + js/ui/components/UploadModal.js | 2 + js/ui/components/mediaUploader.js | 33 +++ js/webtorrent.js | 14 +- tests/blossom-service.test.mjs | 200 +++++++++++++++++ tests/torrent-metadata-schema.test.mjs | 79 +++++++ tests/torrent-metadata-service.test.mjs | 128 +++++++++++ 18 files changed, 1278 insertions(+), 25 deletions(-) create mode 100644 docs/blossom-torrent-metadata-plan.md create mode 100644 js/services/torrentMetadataService.js create mode 100644 tests/torrent-metadata-schema.test.mjs create mode 100644 tests/torrent-metadata-service.test.mjs diff --git a/config/instance-config.js b/config/instance-config.js index 79c4248a..dec628cf 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -292,6 +292,23 @@ export const FEATURE_BITCOIN_CONNECT = true; */ export const FEATURE_BLOSSOM_STORAGE = true; +/** + * FEATURE_BLOSSOM_TORRENT_METADATA — restore WebTorrent/P2P for Blossom-hosted + * videos without hosting a `.torrent` file. Blossom servers reject a `.torrent` + * (415), so a Blossom video's magnet ships webseed-only (no `xs=`) and can't + * bootstrap WebTorrent. With this on, bitvid publishes the torrent piece-map as a + * separate, infohash-keyed Nostr event (kind 30078, `d=bitvid:torrent:`) + * and fetches it lazily only when the torrent path actually runs. + * See docs/blossom-torrent-metadata-plan.md. + * + * Defaults to `false` ("off = no trace"): off ⇒ no companion event is published + * and the playback lookup is skipped (Blossom stays URL-only, exactly as today). + * + * Currently `true` for LOCAL testing of the companion-event flow — reset to + * `false` (shipping default) before pushing/promoting. + */ +export const FEATURE_BLOSSOM_TORRENT_METADATA = true; + /** * Card liveness visibility policy — what a non-owner video card does while its * CDN/WebTorrent liveness probes are still running: diff --git a/css/tailwind.source.css b/css/tailwind.source.css index fcef7561..258b6acb 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -5865,7 +5865,11 @@ .dm-contact-row--active:hover, .dm-contact-row--active:focus-visible { background: color-mix(in srgb, var(--color-accent) 16%, transparent); - border-color: color-mix(in srgb, var(--color-accent) 60%, var(--border-strong)); + border-color: color-mix( + in srgb, + var(--color-accent) 60%, + var(--border-strong) + ); box-shadow: inset 3px 0 0 0 var(--color-accent); } diff --git a/docs/blossom-torrent-metadata-plan.md b/docs/blossom-torrent-metadata-plan.md new file mode 100644 index 00000000..c178cb9b --- /dev/null +++ b/docs/blossom-torrent-metadata-plan.md @@ -0,0 +1,277 @@ +# Blossom videos — WebTorrent metadata via a companion Nostr event — Dev Plan + +Status: **DECISIONS LOCKED (D1–D6, per recommendations, 2026-07-10)** — ready to +build. Restores P2P/WebTorrent playback for Blossom-hosted videos without hosting a +`.torrent` file, by publishing the torrent piece-map as a separate, infohash-keyed +Nostr event that bitvid fetches lazily only when the torrent path actually runs. + +Depends on: `docs/blossom-plan.md` (Blossom storage) and the existing WebTorrent +playback path (`js/webtorrent.js`, `js/services/playbackService.js`). + +## Executive summary + +Blossom servers accept media types only and reject a `.torrent` +(`application/x-bittorrent` → `415 "File type not allowed"`, verified live). A magnet +therefore ships with a webseed (`ws=` = the Blossom URL) but **no `xs=`** (no hosted +`.torrent`). WebTorrent cannot start from a webseed alone — it needs the torrent +**metadata** (the `info` dict / piece map) from either the hosted `.torrent` or a live +peer. So Blossom videos currently publish **URL-only** (they play via direct HTML5, +which works — verified: `video/mp4`, CORS `*`, Range `206` through blossom.band's +`307`→`video.nostr.build`). + +This plan restores P2P by carrying the piece-map **on Nostr**: at upload bitvid already +generates the `.torrent` (that's how it gets the infohash), so it publishes that +`.torrent` as a **companion event addressed by infohash**. bitvid fetches it **only when +a viewer starts the torrent path**, verifies it, then `client.add(torrentBuffer, { +urlList:[videoUrl] })` — WebTorrent now has the map and streams the bytes from the +Blossom webseed over HTTP Range. + +Why a companion event rather than inlining the blob in the video event: the constraint +that bites bitvid is the **feed**, not a single note. bitvid pulls hundreds of video +events per feed load and >90% are watched via direct URL and never touch the torrent +path. Inlining taxes every feed event for a feature most playbacks never use. A separate, +lazily-fetched event keeps the feed lean, removes any practical size cap (the blob is off +the feed), and is nostr-idiomatic: the metadata is **content-addressed by its own hash**. + +## Background — the graceful ladder + +For a video with a valid infohash, bitvid picks the best available P2P story: + +1. **`.torrent` hosted (`xs=`)** — R2/S3, or a self-hosted/paid Blossom server that + accepts `.torrent`. Full magnet, playable in any torrent client. *(unchanged today)* +2. **Companion event** — Blossom URL-only case: `ws=` magnet + an infohash-keyed + metadata event. Playable **in bitvid** (which knows to do the lookup). *(this plan)* +3. **URL-only** — flag off, over cap, or publish failed. Direct HTML5 playback. + *(current Blossom behavior; also the fallback for 1 and 2)* + +The webseed (`ws=`) URL is also just the plain video URL, so **direct playback always +works** regardless of the torrent story — P2P is strictly additive. + +## Applicability across media types + +The trigger keys on **(a valid infohash + a hosted webseed URL)**, not on the "video" +label — so it is inherited by any media that flows through bitvid's upload + torrent seam +(`blossomService.uploadVideo`, i.e. "upload a media blob → generate a `.torrent`"), and +skipped by anything that doesn't. + +| Type | Inherits it? | Why | +|---|---|---| +| **Regular video** | ✅ yes | The seam itself. | +| **Shorts** | ✅ yes, free | A Short is a regular video with `format:"short"` on the same upload+torrent path (`docs/shorts-plan.md` keeps "bitvid's WebTorrent advantage"). Uploaded to Blossom ⇒ same companion. | +| **Audio — publish** (`FEATURE_AUDIO_PUBLISH`, later) | ✅ yes, if it reuses the seam | WebTorrent is file-type-agnostic — an audio blob torrents/webseeds exactly like video. As long as audio *upload* routes through the shared upload+torrent path, no extra work. | +| **Audio — ingest** (`FEATURE_AUDIO_INGEST`) | ❌ no | Surfaces *others'* externally-hosted audio; bitvid uploads nothing, so there is no infohash/webseed to describe. | +| **Live streams** (`FEATURE_LIVE_INGEST`) | ❌ no | An HLS `.m3u8` stream isn't a static file — no fixed byte layout, no deterministic infohash, nothing on Blossom to seed. A **saved VOD later re-uploaded as a normal video** would qualify as a regular video, not as "the live stream." | + +Design implication: keep the torrent + companion orchestration at the **generic +media-blob seam** (it already is — `uploadVideo` is "upload blob + optional torrent", +just video-flavored in name), so new *upload-based* types inherit it for free and +*ingest/stream* types are correctly excluded. No per-type wiring, no per-type schema — the +companion event is infohash-keyed and media-type-agnostic (`d = bitvid:torrent:`). + +## Why not NIP-35 (kinds 2003/2004)? + +Nostr already has a torrent NIP — **NIP-35**: `kind 2003` (torrent) + `kind 2004` +(torrent comment). It carries a torrent's **`x` (infohash)**, `title`, `file` list, +`tracker` list, external-DB `i` references, and category `t` tags. It is deliberately +**not** used here, because it solves a *different* problem: + +- NIP-35 is a torrent **announcement / index** format (a Nostr-native tracker listing). + It records metadata *about* a torrent plus the infohash — but **not the piece hashes / + the bencoded `info` dict / the `.torrent` bytes**. A client that has only a NIP-35 event + still has to obtain the actual torrent metadata from **peers / DHT / trackers**. +- Our problem is exactly that a fresh Blossom video has **no seeders and no hosted + `.torrent`**, so there is nowhere to fetch the piece map from. The companion event exists + to **transport the piece map itself** (the full `.torrent`, base64) so WebTorrent can + reconstruct the torrent and stream from the webseed with **zero peers**. NIP-35 omits the + one field that makes that possible. + +So NIP-35 can't replace the companion event for *playback*. The two are complementary: this +plan handles playback bootstrap; a `kind 2003` could be layered on later purely for +**cross-client discovery** (see Phase 3) without changing this mechanism. Caveat for that +future work: bitvid's webseed-first, no-seeder model doesn't map cleanly onto NIP-35's +"here's a torrent to download" expectation, and emitting a 2003 per video risks noise — +so treat it as opt-in reach, not a default. + +## Decisions + +> **DECISION 1 — Event kind + addressing. ✅ LOCKED.** +> Reuse **NIP-78 `kind:30078`** (bitvid already uses it for `bitvid:storage-connections`) +> as a parameterized-replaceable event with `d = "bitvid:torrent:"` (infohash +> lowercase hex). bitvid derives the key from the magnet's `btih` and queries +> `authors:[videoAuthor], kinds:[30078], #d:["bitvid:torrent:"]`. +> _Rationale: no new kind to define; matches bitvid's existing 30078 namespacing; keyed +> by infohash ⇒ self-verifying and content-addressed. A **dedicated kind** for +> cross-client discoverability is a future option (D6/Phase 3), not MVP._ + +> **DECISION 2 — Payload. ✅ LOCKED.** +> `content` is a small JSON envelope `{"v":1,"infohash":"","torrent":""}` +> where `torrent` is the **full `.torrent` file bytes, base64** (preserves the infohash +> exactly; `client.add` accepts the raw buffer). Tags: `["d","bitvid:torrent:"]`, +> `["x",""]` (NIP-35/94 convention), `["client","bitvid"]`, and a back-reference +> `["e",""]` for debug/discovery (not used for lookup). +> _Rationale: raw `.torrent` = zero reconstruction risk to the infohash; the `v` envelope +> gives forward-compat; the `x` tag makes it conventionally queryable._ + +> **DECISION 3 — Publish trigger. ✅ LOCKED.** +> Publish the companion **only** when: the video has a valid infohash **and** the +> `.torrent` could **not** be hosted (`torrentUrl === ""`, i.e. the Blossom URL-only case) +> **and** `FEATURE_BLOSSOM_TORRENT_METADATA` is on **and** the base64 payload ≤ the cap +> (D4). R2/S3 and `.torrent`-accepting Blossom servers keep using `xs=` and publish no +> companion. +> _Rationale: the companion exists to cover exactly the gap Blossom creates; never +> duplicate what `xs=` already provides._ + +> **DECISION 4 — Size cap (safety, not the feed gate). ✅ LOCKED.** +> Gate on the **actual base64 payload size**, not a file-size proxy (metadata scales +> sub-linearly with file size as WebTorrent grows the piece length; the encoded size is +> known exactly at upload). Default cap **65536 bytes (64 KB)** base64 → URL-only above +> it. A companion event ~this size is off the feed, so the cap is a pathological-case +> guard, not a feature limiter (metadata plateaus ~20–40 KB even for multi-GB videos). +> _Rationale: exact, robust, and generous enough to cover realistic videos._ + +> **DECISION 5 — Playback: lazy fetch + verify + trusted webseed. ✅ LOCKED.** +> On the torrent path, when the magnet has `btih` but no usable `xs=`: query the companion +> by infohash (author-scoped in MVP), base64-decode, **verify the reconstructed torrent's +> infohash === the magnet's `btih`**, then `client.add(buffer, { urlList:[videoUrl], +> announce: WSS_TRACKERS })`. The **webseed URL comes from the trusted video event's `url` +> field** — bitvid **ignores any url-list baked into the `.torrent`**. Cache by infohash. +> Any miss/mismatch/timeout ⇒ URL-only fallback (already the current behavior). +> _Rationale: content from other users is public + untrusted; the infohash check makes +> tampering un-actionable, and sourcing the webseed from the video event prevents a +> malicious companion from redirecting the byte source._ + +> **DECISION 6 — Config flag + scope. ✅ LOCKED.** +> `FEATURE_BLOSSOM_TORRENT_METADATA` in `config/instance-config.js` (default **false**), +> wired like `FEATURE_BLOSSOM_STORAGE` / `FEATURE_BITCOIN_CONNECT`. Off ⇒ no companion is +> published and the playback lookup is skipped (Blossom stays URL-only). MVP is +> **author-scoped** lookup (same pubkey as the video). Crowd-sourced metadata (accept a +> valid companion from **any** author, since it's self-verifying) and a dedicated, +> cross-client kind are **Phase 3**. + +## Config flag (off = no trace) + +- `FEATURE_BLOSSOM_TORRENT_METADATA` (default **false**) — gates both the publish side + (no companion event) and the playback lookup (skip the query). Off ⇒ Blossom behaves + exactly as it does today (URL-only). Constant `BLOSSOM_TORRENT_METADATA_MAX_BASE64 = + 65536` lives in `js/constants.js`. + +## Event shape + +```jsonc +// kind 30078 (NIP-78), parameterized-replaceable, signed by the video author +{ + "kind": 30078, + "tags": [ + ["d", "bitvid:torrent:"], + ["x", ""], + ["client", "bitvid"], + ["e", ""] // back-reference, debug/discovery only + ], + "content": "{\"v\":1,\"infohash\":\"\",\"torrent\":\"\"}" +} +``` + +Builder lives in `js/nostrEventSchemas.js` (source of truth) as +`buildTorrentMetadataEvent({ pubkey, created_at, infoHash, torrentFileBytes })`, alongside +the existing builders. + +## Publish flow (Phase 1) + +In `blossomService.uploadVideo`, inside the existing `if (torrent?.hasValidInfoHash …)` +block, extend the "`.torrent` not hosted" branch: + +1. Compute `b64 = base64(torrent.torrentFile bytes)`. +2. If `FEATURE_BLOSSOM_TORRENT_METADATA` and `b64.length ≤ MAX`: build the companion event + (schema above), sign via the active signer, publish to the user's write relays (reuse + the existing publish helper), and set the result to the **companion tier**: + `magnet = magnet:?xt=urn:btih:&dn=&ws=` (no `xs=`), + `hasValidInfoHash = true`, `infoHash = `, `torrentUrl = ""`. +3. Else (flag off / over cap / publish failed): stay **URL-only** as today + (`magnet = ""`, `hasValidInfoHash = false`). The local `.torrent` is still retained on + `result.torrentFile`. + +Orchestration note: `blossomService` stays WebTorrent-free; the companion publish uses an +injected `publishEvent`/signer callback (same injection pattern as `generateTorrent`), so +the service remains unit-testable in Node. + +## Playback flow (Phase 2) + +Add a `torrentMetadataService` (fetch/verify/cache) consumed by the WebTorrent add path +(`js/webtorrent.js`) or `playbackService`: + +1. Torrent path selected; magnet has `btih:` and no usable `xs=`. +2. Flag on → `getTorrentMetadata({ infoHash, author })`: relay query + `kinds:[30078], authors:[author], #d:["bitvid:torrent:"]`, newest wins. + Cache the decoded buffer by infohash (memory; optional IndexedDB later). +3. Decode envelope → base64-decode → `.torrent` buffer → parse → **assert infohash === + btih** (reject + log on mismatch). +4. `client.add(buffer, { urlList: [videoUrl /* from the video event */], announce: + WSS_TRACKERS })`. WebTorrent has the map; the webseed serves bytes over Range. +5. Any failure ⇒ URL-only (unchanged fallback). Never block first paint: URL-first plays + immediately while this runs in the background. + +## Security + +- **Untrusted content:** verify reconstructed infohash === `btih` before use; on mismatch, + discard and fall back. The infohash is the query key *and* the integrity check. +- **Webseed source:** always the video event's own `url`; never the `.torrent`'s embedded + url-list — prevents byte-source redirection by a malicious companion. +- **Size guard:** refuse to decode a `content` whose base64 exceeds the cap. +- **Author scope (MVP):** query only the video's author, so no cross-author injection. + Crowd-sourcing (Phase 3) stays safe *because* it's self-verifying, but is opt-in later. +- **No new signing surface:** reuse the active signer adapter; respect its circuit-breaker. + +## Phases (flag-gated from day one) + +- **Phase 0 — Flag + schema.** `FEATURE_BLOSSOM_TORRENT_METADATA` wired through + instance-config → config → constants; `BLOSSOM_TORRENT_METADATA_MAX_BASE64`; + `buildTorrentMetadataEvent` in `nostrEventSchemas.js` + docs in + `docs/nostr-event-schemas.md`. No behavior change. +- **Phase 1 — Publish.** Companion publish in the Blossom URL-only branch (D3/D4); promote + Blossom to the **companion tier** magnet when published. Unit tests: builder round-trip, + cap gate, tier selection. +- **Phase 2 — Playback.** `torrentMetadataService` (fetch/verify/cache) + wire into the + WebTorrent add path with trusted-webseed sourcing and URL-only fallback. Unit tests: + infohash-mismatch rejection, webseed sourcing, cache hit. Live check: webseed follows the + Blossom `307` redirect. +- **Phase 3 — Reach (later).** Crowd-sourced lookup (any author), a dedicated cross-client + kind, republish-on-mirror, IndexedDB metadata cache, and an **optional NIP-35 `kind 2003` + companion for cross-client discovery** (complementary to — not a replacement for — the + piece-map event; see "Why not NIP-35" above). + +## Test scenarios (scenario-first; see Dark Factory rules) + +- **Builder round-trip:** a `.torrent` buffer → `buildTorrentMetadataEvent` → event with + `d=bitvid:torrent:`, `x` tag, and `content.torrent` base64 that decodes back to + the exact input bytes. +- **Cap gate:** payload > `MAX` ⇒ no companion, `hasValidInfoHash=false` (URL-only); + ≤ `MAX` ⇒ companion built, `hasValidInfoHash=true`, `ws=`-only magnet, no `xs=`. +- **Verify — reject tampering:** a fetched companion whose reconstructed infohash ≠ `btih` + ⇒ rejected, URL-only fallback taken. +- **Webseed sourcing:** even when the `.torrent` embeds url-list `X`, the add path uses the + video event's `url` as the sole webseed. +- **Round-trip discovery:** publish (mocked relay) → fetch by infohash → reconstruct → + infohash matches → `client.add` invoked with the trusted `urlList`. + +## Risks / watch-items + +- **Companion availability** — if it isn't on the queried relays, P2P is skipped and the + video still plays URL-only. Publish to the user's write relays; consider republish on + re-open (Phase 3). +- **Extra signer prompt at upload** — one more sign (the companion) after the video event. + Reuse the live session; acceptable. Surface it in the upload progress copy. +- **Standalone-client incompatibility** — a `ws=`-only magnet is playable **in bitvid** + (which does the lookup), not in a generic torrent client. Guaranteed generic P2P needs a + hosted `.torrent` (`xs=`, tier 1). Accepted tradeoff. +- **Webseed `307` redirect** — WebTorrent's HTTP webseed must follow blossom.band → + video.nostr.build; browser `fetch` does, but verify live in Phase 2. +- **Very large / pathological piece counts** — metadata may exceed the 64 KB cap ⇒ + URL-only fallback (correct, not a failure). Optional future lever: raise the generated + piece length to shrink metadata (harmless with a webseed serving pieces over Range). + +## Rollback + +Flip `FEATURE_BLOSSOM_TORRENT_METADATA = false`: no companion is published and the +playback lookup is skipped — Blossom instantly reverts to URL-only. Already-published +companion events are inert (bitvid won't query them); they're standard replaceable 30078 +events the author can overwrite/delete via normal Nostr means. diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 0752ca51..1bfbf955 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -201,6 +201,7 @@ Video posts should treat `videoRootId` as the stable series identifier that rema | Submission (`NOTE_TYPES.SUBMISSION`) | `SUBMISSION_KIND` (`30083`, addressable, `d = submission id`) | `['t','bitvid-submission']`, `['d', ]`, `['k', 'application'\|'appeal'\|'bug'\|'feature'\|'feedback']`, `['applicant', ]`, `['p', ]`; free-text body in `content`. | Structured, PUBLIC application/appeal/bug/feature/feedback (#23) — replaces the old kind-4 encrypted DM forms so an admin "Submissions" tab can list + approve/deny. Published from an EPHEMERAL key (no login), so `applicant` is the CLAIMED npub. Built/parsed by `js/submissions/submissionService.js`. | | HTTP Auth (`NOTE_TYPES.HTTP_AUTH`) | `27235` | `['u', ]`, `['method', ]`, `['payload', ]` | Optional plaintext content (NIP-98 recommends empty, but implementations may vary) | | Report (`NOTE_TYPES.REPORT`) | `1984` | `['e', , ]`, `['p', , ]`, and optional `['t', ]` | Plaintext report reason | +| Torrent metadata (`buildTorrentMetadataEvent`) | `30078` (addressable, `d = bitvid:torrent:`) | `['d', 'bitvid:torrent:']`, `['x', ]`, `['client', 'bitvid']`, optional `['e',