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:<infohash>) 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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-11 14:08:53 -04:00
1 parent 56aff1ada0
commit d23cfa6697
18 files changed
+1278 -25

No files matched your search

+17
View File
@@ -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:<infohash>`)
* 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:
+5 -1
View File
@@ -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);
}
+277
View File
@@ -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:<infohash>`).
## 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>"` (infohash
> lowercase hex). bitvid derives the key from the magnet's `btih` and queries
> `authors:[videoAuthor], kinds:[30078], #d:["bitvid:torrent:<infohash>"]`.
> _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":"<hex>","torrent":"<base64>"}`
> where `torrent` is the **full `.torrent` file bytes, base64** (preserves the infohash
> exactly; `client.add` accepts the raw buffer). Tags: `["d","bitvid:torrent:<infohash>"]`,
> `["x","<infohash>"]` (NIP-35/94 convention), `["client","bitvid"]`, and a back-reference
> `["e","<videoEventId>"]` 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:<infohash-lowercase-hex>"],
["x", "<infohash-lowercase-hex>"],
["client", "bitvid"],
["e", "<video-event-id>"] // back-reference, debug/discovery only
],
"content": "{\"v\":1,\"infohash\":\"<hex>\",\"torrent\":\"<base64 .torrent bytes>\"}"
}
```
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:<infoHash>&dn=<name>&ws=<videoUrl>` (no `xs=`),
`hasValidInfoHash = true`, `infoHash = <hex>`, `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:<infohash>` and no usable `xs=`.
2. Flag on → `getTorrentMetadata({ infoHash, author })`: relay query
`kinds:[30078], authors:[author], #d:["bitvid:torrent:<infohash>"]`, 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:<infohash>`, `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.
+1
View File
@@ -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', <id>]`, `['k', 'application'\|'appeal'\|'bug'\|'feature'\|'feedback']`, `['applicant', <npub>]`, `['p', <admin hex>]`; 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', <url>]`, `['method', <http method>]`, `['payload', <payload hash>]` | Optional plaintext content (NIP-98 recommends empty, but implementations may vary) |
| Report (`NOTE_TYPES.REPORT`) | `1984` | `['e', <event id>, <report type>]`, `['p', <pubkey>, <report type>]`, and optional `['t', <report type>]` | Plaintext report reason |
| Torrent metadata (`buildTorrentMetadataEvent`) | `30078` (addressable, `d = bitvid:torrent:<infohash>`) | `['d', 'bitvid:torrent:<infohash>']`, `['x', <infohash>]`, `['client', 'bitvid']`, optional `['e', <video event id>]` | JSON envelope `{ v:1, infohash:<hex>, torrent:<base64 .torrent bytes> }`. The WebTorrent piece-map for a Blossom-hosted video (whose magnet is webseed-only because Blossom rejects `.torrent`). Fetched lazily on the torrent path and **verified: reconstructed infohash must equal the magnet's `btih`**; the webseed URL comes from the video event, never the embedded url-list. Gated by `FEATURE_BLOSSOM_TORRENT_METADATA`. See `docs/blossom-torrent-metadata-plan.md`. |
### Share notes (kind `1`)
+7
View File
@@ -25,7 +25,9 @@ import {
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
FEED_TYPES,
FEATURE_BLOSSOM_TORRENT_METADATA,
} from "./constants.js";
import torrentMetadataService from "./services/torrentMetadataService.js";
import { updateVideoCardSourceVisibility } from "./utils/cardSourceVisibility.js";
import { collectVideoTags } from "./utils/videoTags.js";
import { normalizeHashtag } from "./utils/hashtagNormalization.js";
@@ -642,6 +644,11 @@ class Application {
userLogger,
nostrClient,
torrentClient,
// Torrent piece-map lookup for Blossom videos — passed only when the flag
// is on, so its presence in the coordinator IS the gate.
torrentMetadataService: FEATURE_BLOSSOM_TORRENT_METADATA
? torrentMetadataService
: null,
emit,
accessControl,
isValidMagnetUri,
+50
View File
@@ -41,6 +41,7 @@ export function createPlaybackCoordinator(deps) {
userLogger,
nostrClient,
torrentClient,
torrentMetadataService,
emit,
accessControl,
isValidMagnetUri,
@@ -382,6 +383,52 @@ export function createPlaybackCoordinator(deps) {
? hostedUrlWebSeed.trim()
: "";
// Blossom videos ship a webseed-only magnet (no xs=). When the piece-map
// lookup is wired (flag on) and the magnet lacks xs=, fetch the verified
// .torrent from the video's infohash-keyed companion event so WebTorrent has
// metadata to bootstrap from. Best-effort + verified upstream: null ⇒ the
// bare magnet is used (may still work via peers, else the URL fallback runs).
let companionTorrentBytes = null;
if (
torrentMetadataService &&
typeof extractBtihFromMagnet === "function" &&
typeof magnet === "string" &&
!/[?&]xs=/.test(magnet)
) {
const infoHash = extractBtihFromMagnet(magnet);
const author =
this.currentVideo && typeof this.currentVideo.pubkey === "string"
? this.currentVideo.pubkey
: "";
if (infoHash && author) {
try {
const relays =
Array.isArray(nostrClient?.readRelays) &&
nostrClient.readRelays.length
? nostrClient.readRelays
: nostrClient?.relays || [];
const resolved = await torrentMetadataService.fetch({
infoHash,
author,
// Route relay reads through the subscription manager (one-shot,
// deduped, timeout-bounded) — never pool.list directly.
queryEvents: (filter) =>
nostrClient
.getSubscriptionManager()
.list({ filters: [filter], relays }),
});
if (resolved?.torrentBytes) {
companionTorrentBytes = resolved.torrentBytes;
}
} catch (error) {
devLogger?.warn?.(
"[playback] companion torrent-metadata lookup failed:",
error
);
}
}
}
const attemptStream = async (candidate) => {
const trimmedCandidate =
typeof candidate === "string" ? candidate.trim() : "";
@@ -429,6 +476,9 @@ export function createPlaybackCoordinator(deps) {
{
urlList: sanitizedUrlList,
hostedUrlWebSeed: sanitizedHostedWebSeed,
// Verified piece-map from the companion event (Blossom videos); when
// present, streamVideo adds this buffer instead of the bare magnet.
torrentFileBytes: companionTorrentBytes,
hooks: {
// Surface a mid-stream stall instead of silently freezing on a
// buffering frame: tell the user whether the swarm is just slow
+2
View File
@@ -28,6 +28,7 @@ import {
FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT,
FEATURE_BLOSSOM_STORAGE,
FEATURE_BLOSSOM_TORRENT_METADATA,
CARD_LIVENESS_POLICY,
LIVENESS_PROBE_PREFETCH_MARGIN,
DEFAULT_TRUST_SEED_NPUBS,
@@ -127,6 +128,7 @@ export { FEATURE_NIP71_INGEST };
export { FEATURE_AUDIO_INGEST };
export { FEATURE_BITCOIN_CONNECT };
export { FEATURE_BLOSSOM_STORAGE };
export { FEATURE_BLOSSOM_TORRENT_METADATA };
export { CARD_LIVENESS_POLICY };
export { LIVENESS_PROBE_PREFETCH_MARGIN };
export { DEFAULT_TRUST_SEED_NPUBS };
+18
View File
@@ -10,6 +10,7 @@ import {
FEATURE_AUDIO_INGEST as CONFIG_FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT as CONFIG_FEATURE_BITCOIN_CONNECT,
FEATURE_BLOSSOM_STORAGE as CONFIG_FEATURE_BLOSSOM_STORAGE,
FEATURE_BLOSSOM_TORRENT_METADATA as CONFIG_FEATURE_BLOSSOM_TORRENT_METADATA,
CARD_LIVENESS_POLICY as CONFIG_CARD_LIVENESS_POLICY,
LIVENESS_PROBE_PREFETCH_MARGIN as CONFIG_LIVENESS_PROBE_PREFETCH_MARGIN,
IS_DEV_MODE,
@@ -207,6 +208,11 @@ const DEFAULT_FLAGS = Object.freeze({
// Blossom (nostr-native blob storage) provider (docs/blossom-plan.md, #30).
// Default OFF; only an explicit `true` in the instance config enables it.
FEATURE_BLOSSOM_STORAGE: CONFIG_FEATURE_BLOSSOM_STORAGE === true,
// Blossom videos: publish/lookup the WebTorrent piece-map as a companion Nostr
// event so P2P works without a hosted .torrent (docs/blossom-torrent-metadata-plan.md).
// Default OFF; only an explicit `true` in the instance config enables it.
FEATURE_BLOSSOM_TORRENT_METADATA:
CONFIG_FEATURE_BLOSSOM_TORRENT_METADATA === true,
FEATURE_HASHTAG_PREFERENCES: false,
FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes.
FEATURE_TRUSTED_HIDE_CONTROLS: true,
@@ -236,6 +242,8 @@ const runtimeFlags = (() => {
FEATURE_AUDIO_INGEST: DEFAULT_FLAGS.FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT: DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT,
FEATURE_BLOSSOM_STORAGE: DEFAULT_FLAGS.FEATURE_BLOSSOM_STORAGE,
FEATURE_BLOSSOM_TORRENT_METADATA:
DEFAULT_FLAGS.FEATURE_BLOSSOM_TORRENT_METADATA,
FEATURE_HASHTAG_PREFERENCES: DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES,
FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS,
FEATURE_TRUSTED_HIDE_CONTROLS: DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS,
@@ -296,6 +304,16 @@ export let FEATURE_BLOSSOM_STORAGE = coerceBoolean(
DEFAULT_FLAGS.FEATURE_BLOSSOM_STORAGE
);
export let FEATURE_BLOSSOM_TORRENT_METADATA = coerceBoolean(
runtimeFlags.FEATURE_BLOSSOM_TORRENT_METADATA,
DEFAULT_FLAGS.FEATURE_BLOSSOM_TORRENT_METADATA
);
// Max base64 size of an embedded .torrent piece-map before bitvid falls back to
// URL-only (the companion event is off the feed, so this is a pathological-case
// guard, not a feature limiter). See docs/blossom-torrent-metadata-plan.md (D4).
export const BLOSSOM_TORRENT_METADATA_MAX_BASE64 = 65536;
export let FEATURE_HASHTAG_PREFERENCES = coerceBoolean(
runtimeFlags.FEATURE_HASHTAG_PREFERENCES,
DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES
+82
View File
@@ -2493,6 +2493,88 @@ export function buildViewEvent(params) {
return event;
}
// --- WebTorrent piece-map companion event (NIP-78 app data) ------------------
// Restores P2P for Blossom-hosted videos (whose magnet is webseed-only because
// Blossom rejects a .torrent) by publishing the torrent metadata as a separate,
// infohash-keyed event that bitvid fetches lazily on the torrent path.
// See docs/blossom-torrent-metadata-plan.md.
export const TORRENT_METADATA_KIND = 30078;
export const TORRENT_METADATA_DTAG_PREFIX = "bitvid:torrent:";
export const TORRENT_METADATA_ENVELOPE_VERSION = 1;
// Normalize an infohash to the lowercase-hex form used in the addressing key.
// BitTorrent v1 (WebTorrent) infohashes are 40 hex chars.
export function normalizeInfoHash(infoHash) {
const hex = typeof infoHash === "string" ? infoHash.trim().toLowerCase() : "";
return /^[0-9a-f]{40}$/.test(hex) ? hex : "";
}
// The parameterized-replaceable `d`-tag value that addresses a video's piece-map.
export function torrentMetadataDTag(infoHash) {
const hex = normalizeInfoHash(infoHash);
return hex ? `${TORRENT_METADATA_DTAG_PREFIX}${hex}` : "";
}
/**
* Builds the WebTorrent piece-map companion event (kind 30078). The `content` is a
* versioned JSON envelope carrying the full `.torrent` file bytes (base64); the
* `info` dict inside is what determines the infohash, so storing the raw bytes
* keeps the reconstructed infohash exact. Addressed by `d = bitvid:torrent:<hex>`
* so bitvid can look it up from a magnet's `btih`.
*
* @param {Object} params
* @param {string} params.pubkey - Author (the video's author) hex pubkey.
* @param {number} params.created_at - Unix seconds.
* @param {string} params.infoHash - BitTorrent v1 infohash (40 hex chars).
* @param {string} params.torrentBase64 - base64 of the full `.torrent` file bytes.
* @param {string} [params.videoEventId] - The video event id (back-reference only).
* @returns {Object} Unsigned event.
*/
export function buildTorrentMetadataEvent(params) {
const {
pubkey,
created_at,
infoHash,
torrentBase64,
videoEventId = "",
} = params || {};
const hex = normalizeInfoHash(infoHash);
if (!hex) {
throw new Error(
"buildTorrentMetadataEvent requires a valid 40-hex-char infoHash."
);
}
if (typeof torrentBase64 !== "string" || !torrentBase64) {
throw new Error(
"buildTorrentMetadataEvent requires a non-empty base64 torrent payload."
);
}
const tags = [
["d", `${TORRENT_METADATA_DTAG_PREFIX}${hex}`],
["x", hex],
["client", "bitvid"],
];
if (typeof videoEventId === "string" && videoEventId.trim()) {
tags.push(["e", videoEventId.trim()]);
}
const content = JSON.stringify({
v: TORRENT_METADATA_ENVELOPE_VERSION,
infohash: hex,
torrent: torrentBase64,
});
return {
kind: TORRENT_METADATA_KIND,
pubkey,
created_at,
tags,
content: ensureValidUtf8Content(content),
};
}
/**
* Builds a Zap Request event (Kind 9734).
* Used to request an invoice from a Lightning Service Provider (LSP).
+199 -22
View File
@@ -7,8 +7,27 @@
// signed kind-24242 nostr event (BUD-11) from bitvid's existing signer — no
// access keys. Uploads mirror to multiple servers for resilience (BUD-04).
// See docs/blossom-plan.md / TODO #30.
import { FEATURE_BLOSSOM_STORAGE } from "../constants.js";
import {
FEATURE_BLOSSOM_STORAGE,
BLOSSOM_TORRENT_METADATA_MAX_BASE64,
} from "../constants.js";
import { buildStoragePointerValue } from "../utils/storagePointer.js";
import { devLogger } from "../utils/logger.js";
// base64 of a File/Blob's bytes (browser + Node 20+). Used to embed the
// .torrent piece-map in the companion event. See docs/blossom-torrent-metadata-plan.md.
async function fileToBase64(file) {
const buf = await file.arrayBuffer();
const bytes = new Uint8Array(buf);
if (typeof Buffer !== "undefined") {
return Buffer.from(bytes).toString("base64");
}
let binary = "";
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// Vendored, pinned blossom-client-sdk bundle (scripts/build-blossom-sdk.mjs).
// Lazy-imported so its (small) weight never touches the main bundle.
@@ -41,6 +60,75 @@ export function resolveBlossomServers(credentials) {
return out;
}
// Common media extensions → MIME type. Browsers leave File.type empty for some
// containers (.m4v/.mkv) and for files dragged in without an OS MIME mapping.
// The Blossom SDK uploads `body: file` with no explicit Content-Type, so an empty
// File.type means the PUT declares no type — and a type-allowlisting server (e.g.
// blossom.band) then answers 415. We re-declare the type from the filename.
const MIME_BY_EXTENSION = {
mp4: "video/mp4",
m4v: "video/mp4",
mov: "video/quicktime",
webm: "video/webm",
mkv: "video/x-matroska",
ogv: "video/ogg",
mp3: "audio/mpeg",
m4a: "audio/mp4",
m4b: "audio/mp4",
aac: "audio/aac",
wav: "audio/wav",
flac: "audio/flac",
opus: "audio/opus",
oga: "audio/ogg",
ogg: "audio/ogg",
weba: "audio/webm",
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
avif: "image/avif",
torrent: "application/x-bittorrent",
};
function inferMimeFromName(name) {
const ext =
typeof name === "string" ? name.split(".").pop()?.toLowerCase() : "";
return (ext && MIME_BY_EXTENSION[ext]) || "";
}
// Types we should never trust for a media upload — a recognized extension wins
// over these. Browsers report "" for some containers, and apps/WebTorrent often
// stamp "application/octet-stream" on media files; strict Blossom servers reject
// both with 415/400 even though they accept the real type (e.g. video/mp4).
const GENERIC_TYPES = new Set(["", "application/octet-stream", "binary/octet-stream"]);
// Guarantee the blob carries a correct Content-Type. When the filename has a
// recognized media extension, trust it over an empty or generic browser type and
// re-wrap the blob with the inferred type (bytes are unchanged, so the sha256 is
// too). A specific, non-generic browser type is left as-is.
export function ensureTypedFile(file) {
if (!file || typeof File === "undefined") {
return file;
}
const name = typeof file.name === "string" ? file.name : "upload";
const inferred = inferMimeFromName(name);
const current = typeof file.type === "string" ? file.type : "";
// Nothing to infer, or the browser already declares the exact inferred type.
if (!inferred || current === inferred) {
return file;
}
// Keep a specific, non-generic browser type; only override empty/generic ones.
if (current && !GENERIC_TYPES.has(current)) {
return file;
}
try {
return new File([file], name, { type: inferred });
} catch {
return file;
}
}
export class BlossomService {
constructor(deps = {}) {
this.deps = deps;
@@ -88,6 +176,19 @@ export class BlossomService {
throw new Error("Blossom SDK is missing expected exports.");
}
// Ensure the blob declares a Content-Type — an empty File.type otherwise
// uploads as a typeless PUT and strict servers reject it with 415.
const typedFile = ensureTypedFile(file);
// Surface exactly what we're about to send so a 415 is never a guess:
// the effective Content-Type (from typedFile.type), original type, name, size.
devLogger.log(
`[Blossom] uploading "${typeof typedFile?.name === "string" ? typedFile.name : "(no name)"}" — ` +
`sending Content-Type="${typedFile?.type || "(empty)"}" ` +
`(original File.type="${file?.type || "(empty)"}", ${typedFile?.size ?? "?"} bytes) ` +
`to ${serverList.length} server(s): ${serverList.join(", ")}`,
);
// Upload DIRECTLY to each server (BUD-02 `PUT /upload`), in parallel, rather
// than the SDK's upload-one-then-`/mirror` flow — the server-to-server mirror
// step fails from a browser (CORS) on many servers. A fresh signed upload auth
@@ -96,7 +197,7 @@ export class BlossomService {
createUploadAuth(signer, sha256, { type });
const settled = await Promise.allSettled(
serverList.map((server) =>
uploadBlob(server, file, {
uploadBlob(server, typedFile, {
onAuth,
onProgress: typeof onProgress === "function" ? onProgress : undefined,
}),
@@ -110,9 +211,14 @@ export class BlossomService {
descriptors.push(r.value);
} else {
const reason = r.reason;
failures.push(
`${serverList[i]}: ${reason?.message || reason || "no blob URL returned"}`,
);
// The SDK's HTTPError carries `.status`; surface it so 415 (media type)
// vs 413 (too large) vs 402 (payment) is unambiguous in the message.
const status =
reason && typeof reason.status === "number"
? `HTTP ${reason.status}: `
: "";
const message = reason?.message || reason || "no blob URL returned";
failures.push(`${serverList[i]}: ${status}${message}`);
}
});
@@ -120,8 +226,9 @@ export class BlossomService {
if (!primary) {
throw new Error(
`Blossom upload failed on all server(s) — ${failures.join("; ")}. ` +
`Common causes: the server blocks browser uploads (CORS), rejects the ` +
`file size, or requires payment/allowlisting. Try blossom.band.`,
`Common causes: the file exceeds the server's size limit (blossom.band ` +
`caps free uploads at 20 MiB), the server rejects the media type, blocks ` +
`browser uploads (CORS), or requires payment/allowlisting.`,
);
}
@@ -146,8 +253,20 @@ export class BlossomService {
* @param {(draft:object)=>Promise<object>} params.signer
* @param {(args:{file:File,videoPublicUrl:string})=>Promise<{hasValidInfoHash:boolean,infoHash?:string,torrentFile?:File}>} [params.generateTorrent]
* @param {(fraction:number)=>void} [params.onProgress]
* @param {(args:{infoHash:string,torrentBase64:string})=>Promise<void>} [params.publishTorrentMetadata]
* Publishes the torrent piece-map as a companion Nostr event (Tier 2). When
* provided and `FEATURE_BLOSSOM_TORRENT_METADATA` is on and the `.torrent`
* couldn't be hosted (no `xs=`), a successful publish yields a `ws=`-only magnet
* bitvid can bootstrap. Any failure / over-cap ⇒ URL-only.
*/
async uploadVideo({ file, servers, signer, generateTorrent, onProgress } = {}) {
async uploadVideo({
file,
servers,
signer,
generateTorrent,
onProgress,
publishTorrentMetadata,
} = {}) {
const serverList = resolveBlossomServers({ servers });
if (serverList.length === 0) {
throw new Error("No Blossom servers configured for this connection.");
@@ -182,22 +301,80 @@ export class BlossomService {
}
if (torrent?.hasValidInfoHash && torrent.torrentFile) {
const torrentUploaded = await this.uploadFile({
file: torrent.torrentFile,
servers: serverList,
signer,
type: "upload",
});
const name = typeof file?.name === "string" ? file.name : "video";
result.infoHash = torrent.infoHash;
result.magnet = `magnet:?xt=urn:btih:${torrent.infoHash}&dn=${encodeURIComponent(
name,
)}&ws=${encodeURIComponent(videoPublicUrl)}&xs=${encodeURIComponent(
torrentUploaded.url,
)}`;
result.torrentUrl = torrentUploaded.url;
// Retain the generated .torrent locally so a future/other storage target
// could host it — but don't advertise a magnet yet (see below).
result.torrentFile = torrent.torrentFile;
result.hasValidInfoHash = true;
// Try to host the .torrent alongside the video so the magnet carries xs=
// (the metadata source WebTorrent needs to start). Best-effort: most Blossom
// servers accept media types only and reject a .torrent (415). Only when the
// .torrent is actually hosted do we publish a magnet — a webseed-only magnet
// (no xs=) can't bootstrap WebTorrent and would be a dead share link, so we
// publish URL-only instead. Servers that DO accept .torrent (self-hosted or
// paid) automatically yield a full, P2P-playable magnet with no other change.
let torrentUrl = "";
try {
const torrentUploaded = await this.uploadFile({
file: torrent.torrentFile,
servers: serverList,
signer,
type: "upload",
});
torrentUrl = torrentUploaded.url;
} catch (error) {
devLogger.warn(
`[Blossom] Video uploaded. The .torrent sidecar wasn't hosted ` +
`(Blossom servers accept media only); publishing URL-only — a ` +
`webseed-only magnet can't bootstrap WebTorrent.`,
);
}
const wsMagnet = () =>
`magnet:?xt=urn:btih:${torrent.infoHash}&dn=${encodeURIComponent(name)}` +
`&ws=${encodeURIComponent(videoPublicUrl)}`;
if (torrentUrl) {
// Tier 1 — the .torrent is hosted (xs=): a full magnet, playable in any
// torrent client.
result.infoHash = torrent.infoHash;
result.torrentUrl = torrentUrl;
result.magnet = `${wsMagnet()}&xs=${encodeURIComponent(torrentUrl)}`;
result.hasValidInfoHash = true;
} else if (typeof publishTorrentMetadata === "function") {
// Tier 2 — publish the piece-map as a companion Nostr event (D3/D4) so
// bitvid can bootstrap WebTorrent from the infohash without a hosted
// .torrent. The publisher is only wired when FEATURE_BLOSSOM_TORRENT_METADATA
// is on (that flag gate lives at the mediaUploader seam), so its presence
// IS the gate here. Gate on the exact encoded size (off the feed, but a
// guard); over cap or a failed publish falls through to URL-only.
try {
const torrentBase64 = await fileToBase64(torrent.torrentFile);
if (torrentBase64.length > BLOSSOM_TORRENT_METADATA_MAX_BASE64) {
devLogger.warn(
`[Blossom] Video uploaded. Torrent piece-map is ${torrentBase64.length} ` +
`base64 bytes (> ${BLOSSOM_TORRENT_METADATA_MAX_BASE64}); publishing ` +
`URL-only.`,
);
} else {
await publishTorrentMetadata({
infoHash: torrent.infoHash,
torrentBase64,
});
result.infoHash = torrent.infoHash;
result.magnet = wsMagnet(); // ws= only; metadata is on Nostr
result.hasValidInfoHash = true;
}
} catch (error) {
devLogger.warn(
`[Blossom] Video uploaded, but publishing the torrent metadata ` +
`companion failed; publishing URL-only. Reason: ${
error?.message || error
}`,
);
}
}
// else: URL-only (flag off / no publisher wired) — video already succeeded.
}
return result;
+164
View File
@@ -0,0 +1,164 @@
// Torrent piece-map lookup for Blossom videos (Phase 2 of the WebTorrent-metadata
// companion event). Given a magnet's infohash + the video's author, fetches the
// infohash-keyed kind-30078 companion event, decodes the embedded `.torrent`, and
// VERIFIES that the reconstructed infohash equals the requested one before handing
// the bytes to WebTorrent. Untrusted content: a mismatch (tamper / wrong event) is
// rejected → the caller falls back to URL-only. See docs/blossom-torrent-metadata-plan.md.
import {
TORRENT_METADATA_KIND,
torrentMetadataDTag,
normalizeInfoHash,
} from "../nostrEventSchemas.js";
// base64 → Uint8Array (browser + Node).
export function base64ToBytes(b64) {
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(b64, "base64"));
}
const binary = atob(b64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
function bytesToHex(bytes) {
let hex = "";
for (let i = 0; i < bytes.length; i += 1) {
hex += bytes[i].toString(16).padStart(2, "0");
}
return hex;
}
// Advance past one bencoded value beginning at `i`; returns the index after it.
// Handles integers (`i…e`), byte strings (`<len>:…`), lists (`l…e`) and dicts
// (`d…e`). Throws on anything malformed (callers treat that as "unverifiable").
function skipValue(b, i) {
if (i >= b.length) throw new Error("bencode: truncated");
const c = b[i];
if (c === 0x69 /* i */) {
i += 1;
while (i < b.length && b[i] !== 0x65 /* e */) i += 1;
if (i >= b.length) throw new Error("bencode: unterminated int");
return i + 1;
}
if (c === 0x6c /* l */ || c === 0x64 /* d */) {
i += 1;
while (i < b.length && b[i] !== 0x65 /* e */) i = skipValue(b, i);
if (i >= b.length) throw new Error("bencode: unterminated container");
return i + 1;
}
// byte string: <len>:<bytes>
let len = 0;
let sawDigit = false;
while (i < b.length && b[i] >= 0x30 && b[i] <= 0x39) {
len = len * 10 + (b[i] - 0x30);
sawDigit = true;
i += 1;
}
if (!sawDigit || b[i] !== 0x3a /* : */) throw new Error("bencode: bad string");
i += 1;
if (i + len > b.length) throw new Error("bencode: string overruns buffer");
return i + len;
}
// Return the raw bytes of the top-level `info` value in a `.torrent`, or null.
function extractInfoDictBytes(b) {
if (!(b instanceof Uint8Array) || b[0] !== 0x64 /* d */) return null;
let i = 1;
const decoder = new TextDecoder();
while (i < b.length && b[i] !== 0x65 /* e */) {
// key (byte string)
let len = 0;
let sawDigit = false;
while (i < b.length && b[i] >= 0x30 && b[i] <= 0x39) {
len = len * 10 + (b[i] - 0x30);
sawDigit = true;
i += 1;
}
if (!sawDigit || b[i] !== 0x3a) return null;
i += 1;
if (i + len > b.length) return null;
const key = decoder.decode(b.subarray(i, i + len));
i += len;
const valueStart = i;
i = skipValue(b, i);
if (key === "info") return b.subarray(valueStart, i);
}
return null;
}
// The BitTorrent v1 infohash (SHA-1 of the bencoded `info` dict) of a `.torrent`
// buffer, lowercase hex, or "" if it can't be parsed/hashed.
export async function infoHashFromTorrent(bytes) {
try {
const info = extractInfoDictBytes(bytes);
if (!info) return "";
const digest = await crypto.subtle.digest("SHA-1", info);
return bytesToHex(new Uint8Array(digest));
} catch {
return "";
}
}
export class TorrentMetadataService {
constructor() {
// Cache the verified piece-map by infohash so repeated plays don't re-query.
this.cache = new Map();
}
/**
* Fetch + verify a video's torrent piece-map from its companion event.
*
* @param {object} params
* @param {string} params.infoHash - The magnet's `btih` (40 hex).
* @param {string} [params.author] - The video's author hex pubkey (scopes the query).
* @param {(filter:object)=>Promise<Array<object>>} params.queryEvents - Relay query.
* @returns {Promise<{infoHash:string,torrentBytes:Uint8Array}|null>} null ⇒ URL-only.
*/
async fetch({ infoHash, author, queryEvents } = {}) {
const hex = normalizeInfoHash(infoHash);
if (!hex || typeof queryEvents !== "function") return null;
if (this.cache.has(hex)) return this.cache.get(hex);
const filter = {
kinds: [TORRENT_METADATA_KIND],
"#d": [torrentMetadataDTag(hex)],
};
if (author) filter.authors = [author];
let events;
try {
events = await queryEvents(filter);
} catch {
return null;
}
if (!Array.isArray(events) || events.length === 0) return null;
// Newest event wins.
const newest = events
.filter((e) => e && typeof e.content === "string")
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
if (!newest) return null;
let bytes;
try {
const envelope = JSON.parse(newest.content);
if (!envelope || typeof envelope.torrent !== "string") return null;
bytes = base64ToBytes(envelope.torrent);
} catch {
return null;
}
// SECURITY: only trust the piece-map if it reconstructs to the requested
// infohash. A tampered/wrong event fails this and is discarded.
const actual = await infoHashFromTorrent(bytes);
if (actual !== hex) return null;
const result = { infoHash: hex, torrentBytes: bytes };
this.cache.set(hex, result);
return result;
}
}
const torrentMetadataService = new TorrentMetadataService();
export default torrentMetadataService;
+2
View File
@@ -10,6 +10,7 @@ import { Nip71FormManager } from "./nip71FormManager.js";
import { MediaUploader } from "./mediaUploader.js";
import { initEditModalUpload } from "./editModalUpload.js";
import { getActiveSigner } from "../../nostrClientRegistry.js";
import { nostrClient } from "../../nostrClientFacade.js";
import { devLogger } from "../../utils/logger.js";
export class EditModal {
@@ -53,6 +54,7 @@ export class EditModal {
safeEncodeNpub:
typeof safeEncodeNpub === "function" ? safeEncodeNpub : (p) => p,
getSigner: () => getActiveSigner(),
signAndPublishEvent: (event) => nostrClient.signAndPublishEvent(event),
});
this.getMode =
typeof getMode === "function"
+2
View File
@@ -26,6 +26,7 @@ import {
deriveStoragePointerFromUrl,
} from "../../utils/storagePointer.js";
import {
nostrClient,
getActiveSigner,
requestDefaultExtensionPermissions,
} from "../../nostrClientFacade.js";
@@ -101,6 +102,7 @@ export class UploadModal {
this.getCurrentPubkey ? this.getCurrentPubkey() : null,
safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey),
getSigner: () => getActiveSigner(),
signAndPublishEvent: (event) => nostrClient.signAndPublishEvent(event),
});
this.eventTarget =
eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
+33
View File
@@ -29,6 +29,8 @@ import blossomServiceDefault, {
isBlossomProvider,
resolveBlossomServers,
} from "../../services/blossomService.js";
import { buildTorrentMetadataEvent } from "../../nostrEventSchemas.js";
import { FEATURE_BLOSSOM_TORRENT_METADATA } from "../../constants.js";
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
@@ -53,6 +55,7 @@ export class MediaUploader {
safeEncodeNpub,
blossomService,
getSigner,
signAndPublishEvent,
} = {}) {
this.r2Service = r2Service || null;
this.s3Service = s3Service || null;
@@ -65,6 +68,11 @@ export class MediaUploader {
// provider is "blossom", so S3/R2 uploads are completely unaffected.
this.blossomService = blossomService || blossomServiceDefault;
this.getSigner = typeof getSigner === "function" ? getSigner : null;
// Sign + publish an unsigned Nostr event to the user's relays. Used only to
// publish the WebTorrent piece-map companion event for Blossom videos when
// FEATURE_BLOSSOM_TORRENT_METADATA is on. See docs/blossom-torrent-metadata-plan.md.
this.signAndPublishEvent =
typeof signAndPublishEvent === "function" ? signAndPublishEvent : null;
}
serviceFor(provider) {
@@ -379,6 +387,30 @@ export class MediaUploader {
return { url: uploaded.url, key: uploaded.key };
}
// Build the flag-gated companion-metadata publisher passed to blossomService
// (Tier 2). Returns undefined unless FEATURE_BLOSSOM_TORRENT_METADATA is on, a
// publish path is wired, and the active pubkey is known — in which case
// blossomService only calls it when the .torrent couldn't be hosted.
resolveTorrentMetadataPublisher() {
if (!FEATURE_BLOSSOM_TORRENT_METADATA || !this.signAndPublishEvent) {
return undefined;
}
const pubkey =
typeof this.getCurrentPubkey === "function" ? this.getCurrentPubkey() : "";
if (!pubkey || typeof pubkey !== "string") {
return undefined;
}
return async ({ infoHash, torrentBase64 }) => {
const event = buildTorrentMetadataEvent({
pubkey,
created_at: Math.floor(Date.now() / 1000),
infoHash,
torrentBase64,
});
await this.signAndPublishEvent(event);
};
}
async uploadVideoToBlossom(file, { credentials, onProgress } = {}) {
const emit = (fraction, label) => {
if (typeof onProgress === "function") onProgress({ fraction, label });
@@ -396,6 +428,7 @@ export class MediaUploader {
signer,
generateTorrent: (args) => this.generateTorrentMetadata(args),
onProgress: (fraction) => emit(fraction, null),
publishTorrentMetadata: this.resolveTorrentMetadataPublisher(),
});
emit(
1,
+12 -2
View File
@@ -951,12 +951,22 @@ export class TorrentClient {
chromeOptions.urlList = candidateUrls;
}
// When the caller supplies verified .torrent bytes (the piece-map from a
// Blossom video's companion event — already infohash-checked upstream), add
// that buffer directly so WebTorrent has metadata without a peer or xs=.
// The trusted webseed still rides along via chromeOptions.urlList.
const torrentSource =
opts?.torrentFileBytes instanceof Uint8Array &&
opts.torrentFileBytes.length
? opts.torrentFileBytes
: magnetURI;
return new Promise((resolve, reject) => {
// 3) Add the torrent to the client and handle accordingly.
if (isFirefoxBrowser) {
this.log("Starting torrent download (Firefox path)");
this.client.add(
magnetURI,
torrentSource,
{ ...chromeOptions, maxWebConns: 4 },
(torrent) => {
this.log("Torrent added (Firefox path):", torrent.name);
@@ -972,7 +982,7 @@ export class TorrentClient {
);
} else {
this.log("Starting torrent download (Chrome path)");
this.client.add(magnetURI, chromeOptions, (torrent) => {
this.client.add(torrentSource, chromeOptions, (torrent) => {
this.log("Torrent added (Chrome path):", torrent.name);
this.handleTorrentStream(
torrent,
+200
View File
@@ -111,6 +111,68 @@ test("uploadFile throws a descriptive error when ALL servers fail", async () =>
);
});
test("uploadFile declares a Content-Type when the File has none (avoids 415)", async () => {
// Browsers leave File.type empty for containers like .m4v; the SDK then PUTs a
// typeless body and strict servers answer 415. uploadFile must re-type the blob.
const { svc, uploads } = mockService();
const typeless = new File([new Uint8Array([1])], "clip.m4v");
assert.equal(typeless.type, "", "precondition: the File has no MIME type");
await svc.uploadFile({ file: typeless, servers: ["https://a"], signer });
assert.equal(
uploads[0].file.type,
"video/mp4",
"the uploaded blob was re-typed from the .m4v extension",
);
});
test("uploadFile overrides a generic application/octet-stream type from the extension", async () => {
// Files produced by other apps or fetched via WebTorrent often carry a generic
// "application/octet-stream" type; strict servers reject that with 415/400 even
// though they accept the real type. A recognized extension must win.
const { svc, uploads } = mockService();
const generic = new File([new Uint8Array([1])], "Fire Pit Render.mp4", {
type: "application/octet-stream",
});
await svc.uploadFile({ file: generic, servers: ["https://a"], signer });
assert.equal(
uploads[0].file.type,
"video/mp4",
"generic octet-stream is replaced by video/mp4 from the .mp4 extension",
);
});
test("uploadFile keeps a specific, correct browser type untouched", async () => {
const { svc, uploads } = mockService();
const good = new File([new Uint8Array([1])], "clip.mkv", { type: "video/webm" });
await svc.uploadFile({ file: good, servers: ["https://a"], signer });
// A specific (non-generic) browser type is trusted even if it differs from the
// extension's inferred type — we don't second-guess a real declared type.
assert.equal(uploads[0].file.type, "video/webm");
});
test("uploadFile surfaces the server HTTP status in the all-fail error", async () => {
// Simulate a Blossom server rejecting the media type (415). The SDK's HTTPError
// exposes `.status`; the thrown message must include it, not just a vague reason.
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async () => ({ kind: 24242 }),
uploadBlob: async () => {
const err = new Error("Something went wrong");
err.status = 415;
throw err;
},
});
await assert.rejects(
() =>
svc.uploadFile({
file: new File([new Uint8Array([1])], "v.mp4"),
servers: ["https://a"],
signer,
}),
/HTTP 415: Something went wrong/,
);
});
test("uploadVideo without a torrent returns a blossom storagePointer and no magnet", async () => {
const { svc } = mockService();
const result = await svc.uploadVideo({
@@ -151,6 +213,144 @@ test("uploadVideo with a torrent uploads the .torrent as a blob and builds a mag
assert.equal(result.torrentUrl, `https://a/${"b".repeat(64)}`);
});
test("uploadVideo publishes URL-only when the .torrent sidecar is rejected (415)", async () => {
// Spec correction: a webseed-only magnet (ws= but no xs=) can't bootstrap
// WebTorrent — there's no metadata source — so shipping one produces a dead
// share link. When a Blossom server rejects the .torrent (415), uploadVideo must
// succeed with the video URL but publish NO magnet (URL-only), while retaining
// the generated .torrent locally for other storage targets.
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async () => ({ kind: 24242 }),
uploadBlob: async (server, file) => {
if (file?.name?.includes(".torrent")) {
const err = new Error("File type not allowed, unsupported");
err.status = 415;
throw err;
}
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
},
});
const infoHash = "0".repeat(40);
const torrentFile = new File([new Uint8Array([9])], "clip.torrent");
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1, 2])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async () => ({ hasValidInfoHash: true, infoHash, torrentFile }),
});
assert.equal(result.url, `https://a/${SHA}`, "video url is returned");
assert.equal(result.magnet, "", "no magnet is published when the .torrent isn't hosted");
assert.equal(result.torrentUrl, "", "no hosted .torrent url after rejection");
assert.equal(result.hasValidInfoHash, false, "no usable magnet is advertised");
assert.equal(result.torrentFile, torrentFile, "the generated .torrent is retained locally");
});
test("uploadVideo Tier 2: publishes a companion metadata event → ws=-only magnet", async () => {
// .torrent rejected by the server (415), but a publisher is wired (flag on at the
// mediaUploader seam): bitvid publishes the piece-map companion event and ships a
// ws=-only magnet it can bootstrap from Nostr. No xs= (nothing hosted).
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async () => ({ kind: 24242 }),
uploadBlob: async (server, file) => {
if (file?.name?.includes(".torrent")) {
const err = new Error("File type not allowed");
err.status = 415;
throw err;
}
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
},
});
const infoHash = "0".repeat(40);
const published = [];
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1, 2])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async () => ({
hasValidInfoHash: true,
infoHash,
torrentFile: new File([new Uint8Array([9, 9, 9])], "clip.torrent"),
}),
publishTorrentMetadata: async (args) => published.push(args),
});
assert.equal(published.length, 1, "the companion was published exactly once");
assert.equal(published[0].infoHash, infoHash);
assert.equal(
Buffer.from(published[0].torrentBase64, "base64").length,
3,
"the exact .torrent bytes were base64-encoded",
);
assert.equal(result.hasValidInfoHash, true);
assert.match(result.magnet, new RegExp(`btih:${infoHash}`));
assert.match(result.magnet, /ws=https%3A%2F%2Fa%2F/, "webseed points at the video");
assert.ok(!/xs=/.test(result.magnet), "no xs= — the .torrent isn't hosted");
});
test("uploadVideo Tier 2: over-cap piece-map falls back to URL-only", async () => {
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async () => ({ kind: 24242 }),
uploadBlob: async (server, file) => {
if (file?.name?.includes(".torrent")) {
const err = new Error("File type not allowed");
err.status = 415;
throw err;
}
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
},
});
const published = [];
// A .torrent whose base64 exceeds the 64 KiB cap.
const big = new File([new Uint8Array(60 * 1024)], "big.torrent");
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async () => ({
hasValidInfoHash: true,
infoHash: "0".repeat(40),
torrentFile: big,
}),
publishTorrentMetadata: async (args) => published.push(args),
});
assert.equal(published.length, 0, "over-cap ⇒ nothing published");
assert.equal(result.magnet, "", "URL-only");
assert.equal(result.hasValidInfoHash, false);
});
test("uploadVideo Tier 2: a failed companion publish falls back to URL-only", async () => {
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async () => ({ kind: 24242 }),
uploadBlob: async (server, file) => {
if (file?.name?.includes(".torrent")) {
const err = new Error("File type not allowed");
err.status = 415;
throw err;
}
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
},
});
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async () => ({
hasValidInfoHash: true,
infoHash: "0".repeat(40),
torrentFile: new File([new Uint8Array([9])], "clip.torrent"),
}),
publishTorrentMetadata: async () => {
throw new Error("relay unreachable");
},
});
assert.equal(result.magnet, "", "publish failed ⇒ URL-only, video still succeeds");
assert.equal(result.hasValidInfoHash, false);
assert.equal(result.url, `https://a/${SHA}`);
});
// --- BUD-12 management primitives (Phase 2) ---
test("deleteFile guards + calls deleteBlob with a delete auth", async () => {
+79
View File
@@ -0,0 +1,79 @@
// buildTorrentMetadataEvent + infohash-addressing helpers (Phase 0 of the Blossom
// WebTorrent-metadata companion event). Covers the event shape, the base64
// round-trip, the addressing key, and input guards. See
// docs/blossom-torrent-metadata-plan.md.
import test from "node:test";
import assert from "node:assert/strict";
import {
buildTorrentMetadataEvent,
normalizeInfoHash,
torrentMetadataDTag,
TORRENT_METADATA_KIND,
} from "../js/nostrEventSchemas.js";
const INFOHASH = "0123456789abcdef0123456789abcdef01234567"; // 40 hex
const TORRENT_B64 = Buffer.from("d4:infod6:lengthi1eee").toString("base64");
const PK = "a".repeat(64);
test("normalizeInfoHash lowercases valid 40-hex and rejects the rest", () => {
assert.equal(normalizeInfoHash(INFOHASH.toUpperCase()), INFOHASH);
assert.equal(normalizeInfoHash(` ${INFOHASH} `), INFOHASH);
assert.equal(normalizeInfoHash("not-hex"), "");
assert.equal(normalizeInfoHash("abc"), "", "too short");
assert.equal(normalizeInfoHash("a".repeat(64)), "", "sha256-length is not a v1 infohash");
assert.equal(normalizeInfoHash(null), "");
});
test("torrentMetadataDTag builds the addressing key, empty on bad input", () => {
assert.equal(torrentMetadataDTag(INFOHASH), `bitvid:torrent:${INFOHASH}`);
assert.equal(torrentMetadataDTag("nope"), "");
});
test("buildTorrentMetadataEvent produces a kind-30078 event keyed by infohash", () => {
const event = buildTorrentMetadataEvent({
pubkey: PK,
created_at: 1700000000,
infoHash: INFOHASH.toUpperCase(), // caller may pass any case
torrentBase64: TORRENT_B64,
videoEventId: "vid-event-id",
});
assert.equal(event.kind, TORRENT_METADATA_KIND);
assert.equal(event.pubkey, PK);
const dTag = event.tags.find((t) => t[0] === "d");
assert.deepEqual(dTag, ["d", `bitvid:torrent:${INFOHASH}`], "addressed by lowercase infohash");
assert.deepEqual(event.tags.find((t) => t[0] === "x"), ["x", INFOHASH]);
assert.deepEqual(event.tags.find((t) => t[0] === "client"), ["client", "bitvid"]);
assert.deepEqual(event.tags.find((t) => t[0] === "e"), ["e", "vid-event-id"]);
});
test("buildTorrentMetadataEvent content round-trips the exact .torrent bytes", () => {
const event = buildTorrentMetadataEvent({
pubkey: PK,
created_at: 1700000000,
infoHash: INFOHASH,
torrentBase64: TORRENT_B64,
});
const envelope = JSON.parse(event.content);
assert.equal(envelope.v, 1);
assert.equal(envelope.infohash, INFOHASH);
assert.equal(envelope.torrent, TORRENT_B64);
// The recovered bytes are byte-identical to what was embedded.
assert.equal(
Buffer.from(envelope.torrent, "base64").toString(),
"d4:infod6:lengthi1eee",
);
// No back-reference tag when videoEventId is omitted.
assert.equal(event.tags.some((t) => t[0] === "e"), false);
});
test("buildTorrentMetadataEvent rejects an invalid infohash or empty payload", () => {
assert.throws(
() => buildTorrentMetadataEvent({ pubkey: PK, created_at: 1, infoHash: "bad", torrentBase64: TORRENT_B64 }),
/valid 40-hex-char infoHash/,
);
assert.throws(
() => buildTorrentMetadataEvent({ pubkey: PK, created_at: 1, infoHash: INFOHASH, torrentBase64: "" }),
/non-empty base64 torrent payload/,
);
});
+128
View File
@@ -0,0 +1,128 @@
// torrentMetadataService: fetch → decode → VERIFY infohash → cache, plus the
// bencode/SHA-1 infohash computation. Builds a real .torrent in-test so the verify
// is exercised end-to-end (not circularly). See docs/blossom-torrent-metadata-plan.md.
import test from "node:test";
import assert from "node:assert/strict";
import {
TorrentMetadataService,
infoHashFromTorrent,
base64ToBytes,
} from "../js/services/torrentMetadataService.js";
import { torrentMetadataDTag } from "../js/nostrEventSchemas.js";
// --- tiny bencode encoder (test-only) ---
function bstr(s) {
const b = Buffer.from(s);
return Buffer.concat([Buffer.from(`${b.length}:`), b]);
}
function bbytes(buf) {
return Buffer.concat([Buffer.from(`${buf.length}:`), buf]);
}
function bint(n) {
return Buffer.from(`i${n}e`);
}
// A minimal, valid info dict + a full .torrent wrapping it.
const pieces = Buffer.alloc(20, 7); // one 20-byte SHA-1 placeholder
const infoDict = Buffer.concat([
Buffer.from("d"),
bstr("length"), bint(3),
bstr("name"), bstr("clip.mp4"),
bstr("piece length"), bint(16384),
bstr("pieces"), bbytes(pieces),
Buffer.from("e"),
]);
const torrent = Buffer.concat([
Buffer.from("d"),
bstr("announce"), bstr("wss://tracker.example"),
bstr("info"), infoDict,
Buffer.from("e"),
]);
async function sha1Hex(buf) {
const d = await crypto.subtle.digest("SHA-1", buf);
return Buffer.from(new Uint8Array(d)).toString("hex");
}
function companionEvent(infoHash, torrentBuf, created_at = 1000) {
return {
kind: 30078,
created_at,
tags: [["d", torrentMetadataDTag(infoHash)]],
content: JSON.stringify({ v: 1, infohash: infoHash, torrent: torrentBuf.toString("base64") }),
};
}
test("infoHashFromTorrent isolates the info dict and SHA-1s exactly those bytes", async () => {
const expected = await sha1Hex(infoDict); // hash of ONLY the info dict
const actual = await infoHashFromTorrent(new Uint8Array(torrent));
assert.equal(actual, expected, "infohash = SHA-1 of the bencoded info dict");
assert.equal(actual.length, 40);
});
test("infoHashFromTorrent returns '' for non-torrent / malformed bytes", async () => {
assert.equal(await infoHashFromTorrent(new Uint8Array([1, 2, 3])), "");
assert.equal(await infoHashFromTorrent(new Uint8Array(Buffer.from("d4:spam"))), "");
});
test("fetch returns the verified piece-map for a matching companion event", async () => {
const infoHash = await infoHashFromTorrent(new Uint8Array(torrent));
const svc = new TorrentMetadataService();
let queries = 0;
const out = await svc.fetch({
infoHash,
author: "a".repeat(64),
queryEvents: async (filter) => {
queries += 1;
assert.deepEqual(filter["#d"], [torrentMetadataDTag(infoHash)], "queried by infohash key");
assert.deepEqual(filter.authors, ["a".repeat(64)], "author-scoped");
return [companionEvent(infoHash, torrent)];
},
});
assert.ok(out, "returns a result");
assert.equal(out.infoHash, infoHash);
assert.equal(Buffer.from(out.torrentBytes).equals(torrent), true, "exact .torrent bytes");
assert.equal(queries, 1);
});
test("fetch REJECTS a companion whose bytes don't match the requested infohash", async () => {
// Ask for a different infohash than the .torrent actually reconstructs to.
const wrongHash = "1".repeat(40);
const svc = new TorrentMetadataService();
const out = await svc.fetch({
infoHash: wrongHash,
queryEvents: async () => [companionEvent(wrongHash, torrent)], // torrent hashes to something else
});
assert.equal(out, null, "infohash mismatch ⇒ rejected (tamper-resistant)");
});
test("fetch returns null when not found or malformed", async () => {
const infoHash = await infoHashFromTorrent(new Uint8Array(torrent));
const svc = new TorrentMetadataService();
assert.equal(await svc.fetch({ infoHash, queryEvents: async () => [] }), null, "not found");
assert.equal(
await svc.fetch({ infoHash, queryEvents: async () => [{ content: "not json" }] }),
null,
"malformed content",
);
assert.equal(await svc.fetch({ infoHash: "nope", queryEvents: async () => [] }), null, "bad infohash");
});
test("fetch caches the verified result (no second relay query)", async () => {
const infoHash = await infoHashFromTorrent(new Uint8Array(torrent));
const svc = new TorrentMetadataService();
let queries = 0;
const q = async () => {
queries += 1;
return [companionEvent(infoHash, torrent)];
};
await svc.fetch({ infoHash, queryEvents: q });
await svc.fetch({ infoHash, queryEvents: q });
assert.equal(queries, 1, "second fetch served from cache");
});
test("base64ToBytes round-trips", () => {
const bytes = base64ToBytes(Buffer.from([1, 2, 3, 250]).toString("base64"));
assert.deepEqual([...bytes], [1, 2, 3, 250]);
});