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',