Decided to keep the dual-event model (kind 30078 canonical + additive 34235/36 mirror) rather than collapse to a single canonical 34235 note. Captures the rationale: private videos can't be public NIP-71 (no encryption), and kind 30078 is the addressing root (watch-history/comments/reactions/views/playlists/naddr/ moderation + hardcoded video filters) so switching it is a large forward-only migration not worth the interop win right now. Accepted trade-off: keep the mirror in sync on edit/delete (Phase 1 polish). Phase 0/1 builder/parser remain reusable if single-canonical is ever revisited. Co-Authored-By: Claude Opus 4.8 <[email protected]>
17 KiB
NIP-71 Interop Plan for bitvid
Status: planning / spec (no code yet). Last updated 2026-06-22.
Goal: let a publisher opt in to making a bitvid video fully NIP-71 compliant so it (a) shows up on other Nostr video clients (Nostube, Amethyst, Flare, zap.stream recordings, …) and (b) still carries bitvid's extra data (WebTorrent, flags) for a richer experience inside bitvid. Eventually bitvid also ingests other clients' NIP-71 videos. Decisions below were made with the maintainer; see "Decisions".
Decisions (locked)
- Kind: addressable
34235(normal) /34236(short). - Opt-in, off by default — per-video toggle; private videos never mirrored.
- Scope: outbound first, inbound later (Phase 1 vs Phase 2).
- Dual-event model (decided 2026-06-22), NOT single-canonical. Considered
collapsing to one note type by making bitvid's canonical video event itself
kind 34235/36. Verdict: keep kind 30078 canonical + an additive mirror.
- Why a single note is appealing: one event, no drift, fully discoverable.
- Why we're not doing it (yet): (a) private videos can't be public NIP-71
(no encryption) so they'd stay 30078 regardless — "one note for everything"
is impossible, only "one note for public"; (b) kind 30078 is the addressing
root —
30078:pubkey:dis referenced by watch-history pointers, comments, reactions, view counts, playlists, naddr deep-links, and moderation, plus hardcoded video subscription filters (nostrService.VIDEO_KIND,videoPointer.DEFAULT_VIDEO_KIND,kinds:[30078]across client/channel/ search). Switching the canonical kind ripples through all of them and needs a forward-only dual-read migration. Not worth the risk for the interop win. - Accepted trade-off: two events per shared video; the mirror can drift from the canonical, so the lifecycle must keep them in sync (auto re-publish on edit / remove on delete) — see Phase 1 polish.
- The Phase 0/1 builder+parser are reusable if we ever revisit single-canonical (they'd become bitvid's native 34235 read/write layer).
Why 34235/34236 (research-validated)
The earlier assumption that 34235/36 were "deprecated legacy" was wrong. The current NIP-71 (master) defines all four kinds and explicitly designates the addressable pair for editable content:
"For content that may need updates after publication (such as correcting metadata, descriptions, or handling URL migrations), addressable versions are available: Kind
34235… Kind34236… include adtag as a unique identifier and can be updated while maintaining the same addressable reference."
That is exactly bitvid's model (replaceable kind 30078, edit-in-place via
d = videoRootId). So 34235/36 is not a compromise — it's the spec-recommended
kind for what bitvid does, and it's what Nostube/Goblinbox consume today.
Hedge: keep the builder kind-parameterized so we can also emit / switch to
21/22(regular) later without a rewrite if the ecosystem leans that way.
Architecture: dual-event, additive
- Canonical stays kind 30078 (NIP-78 app-data), unchanged. Source of truth for WebTorrent, private videos, moderation, view counts — none of which NIP-71 models.
- Mirror = kind 34235/34236, addressable with
d=videoRootId(same d-tag as the 30078), so the mirror edits/deletes in lockstep with the canonical. - Correlation is automatic via
(pubkey, videoRootId). We also set:["origin", "bitvid", "<videoRootId>", "https://bitvid.network/?v=<nevent|naddr>"]so other clients can attribute + deep-link back ("watch on bitvid").- Optionally
["a", "30078:<pubkey>:<videoRootId>"]back-pointer (harmless elsewhere).
- bitvid never double-renders its own mirror. Inside bitvid the 34235/36 is
merged onto its 30078 by
videoRootId(nip71Cachealready keys on it) — it is metadata enrichment, never a second feed card. This dedup applies from Phase 1 (our own mirror), not just Phase 2 (foreign videos).
Field mapping: 30078 JSON → 34235/36 tags
Sourced against NIP-71 + NIP-92 (imeta) + NIP-94 (file metadata fields).
| bitvid (30078 content) | NIP-71 / imeta | notes |
|---|---|---|
| — | ["d", videoRootId] |
required for addressable; lockstep with 30078 |
title |
["title", …] |
required |
description |
.content (full description) |
|
| short text | ["alt", …] |
accessibility text — title / first line, NOT a dump of the full description |
| first publish time | ["published_at", …] |
read from the ORIGINAL 30078 (root created_at/published_at); stable across edits so foreign clients don't reorder |
url (HTTPS) |
imeta url <…> |
primary playable source for foreign clients |
| derived MIME | imeta m <…> |
not stored today → derive from extension, default video/mp4 |
thumbnail |
imeta image <…> |
|
duration (upload #input-duration) |
imeta duration <sec> |
already collected at upload — wire it through |
| dimensions (probe at upload) | imeta dim <w>x<h> + 34235-vs-34236 selection |
not captured today → add a loadedmetadata probe |
fileSha256 |
imeta x <…> |
|
originalFileSha256 |
imeta ox <…> |
|
magnet |
imeta magnet <magnet:?xt=…&ws=…&xs=…> |
standard NIP-94 field — torrent-aware clients can use it |
infoHash |
imeta i <infohash> |
standard NIP-94 field |
ws / xs |
encoded into the magnet URI (&ws=/&xs=) |
also kept on 30078 canonical |
isNsfw |
["content-warning", "<reason>"] |
standard |
| hashtags | ["t", <tag>] (repeated) |
written to both 30078 and the mirror; see "Hashtags & the feed" |
isForKids, mode, etc. |
bitvid-namespaced extra tags | ignored by other clients |
| — | ["client", "bitvid"] |
Two hard rules that fall out of this:
- Private videos are never mirrored. NIP-71 has no encryption; mirroring would leak them. The toggle is disabled (with explanation) for private videos.
- A hosted HTTPS
urlis required to mirror. Foreign clients can't play a magnet-only video; bitvid's magnet still rides along (magnet/i) for torrent-aware clients, buturlmust be present or the toggle is disabled. - The
urlmust be publicly reachable (CORS + range requests) so other clients can actually play it. Gate the toggle on bitvid's existing public-access verification (verifyPublicAccess) — don't advertise a video elsewhere that only bitvid can fetch.
Timestamp hygiene: created_at = actual signing time; published_at = display
time. (Avoids the future-created_at weirdness seen in some published events.)
Hashtags & the feed (no algo changes)
The explore/kids feed scoring already consumes NIP-71 hashtags:
js/feedEngine/exploreScoring.js builds each video's tag vector from both the
raw ["t", …] tags on the event and video.nip71.hashtags, then matches it
against the user's interests/disinterests vector. nip71.js already parses t
tags into metadata.hashtags and merges NIP-71 metadata onto videos by
videoRootId (nip71Cache). So a native 30078 video, a 34235/36 mirror, and an
ingested foreign NIP-71 video all slot into the same hashtag-driven scoring —
the feed algorithm needs zero changes.
The only gap is capture: bitvid's current 30078 content schema has no per-video
hashtags field (videos only get the topic tag ["t","video"]). So the work is to
add a hashtags input and emit ["t", tag] on both events. This is part of
Phase 1.
Converting existing videos (the edit/upgrade flow)
Opting an existing video into NIP-71 is just an edit. When the publisher toggles
the mirror on (or edits an already-mirrored video), bitvid maps every available
30078 field into its NIP-71 home per the table above — title→title,
description→.content/alt, url→imeta url, thumbnail→imeta image,
magnet/infoHash→imeta magnet/i, nsfw→content-warning, hashtags→t, etc. —
and writes the 34235/36 mirror with d = videoRootId so it stays addressable and
lockstep-editable.
Practical notes for conversion:
- Existing videos have no user hashtags yet, so the edit form surfaces the new hashtags input; tags the publisher adds are written to both the 30078 and the mirror (keeping the feed unified, native + mirrored).
- Conversion is non-destructive: the 30078 stays canonical; the mirror is additive.
- Re-editing later updates both in lockstep (same
d-tag); toggling off NIP-09s the mirror only.
Discovery & relays (the part that decides if anyone sees it)
A format-perfect mirror is invisible if it isn't on the relays other clients read. This is the #1 thing that makes or breaks interop, and it's a relay problem, not a format problem.
- bitvid already has NIP-65 (
relayManager.publishRelayList, kind 10002, + outbox reads), andpublishVideotargetsthis.writeRelays. The mirror must publish to the user's NIP-65 write relays (outbox model) — that's where outbox-aware clients (Amethyst, etc.) look for that author's content. - Ensure the user has a published, accurate NIP-65 list when they first enable the mirror (publish kind 10002 if missing). Without it, outbox clients can't find the video regardless of correctness.
- Consider also fanning the mirror out to a small set of well-known general/video relays so non-outbox clients (and search) can discover it. Keep this list short and configurable; respect the relay-fan-out cap lessons (don't storm).
- Relay reads for any future ingest go through the subscription manager
(lint:pool-access), never
pool.listdirectly.
Phases
Phase 0 — Retarget builders (no UX; flag stays off)
- Point existing
buildNip71VideoEventat 34235/36 +d=videoRootId(it currently targets 21/22). Implement the field mapping incl.magnet/i/ox/duration(already collected at upload). - Normal-vs-short selection: default 34235; choose 34236 when known-portrait (from captured dimensions; otherwise a manual "short" toggle).
- Cheat-resistant tests: bitvid video → spec-valid 34235 event → round-trips back
via
convertEventToVideo; magnet/infohash present; private refused; url-less refused. - Interop golden test: assert the produced event matches the spec shape
(
d/title/published_at/imeta url+image) so a foreign parser (Nostube-style) can read it — guards against silent shape drift.
Phase 1 — Outbound publish/edit/delete (the headline feature)
- Edit form toggle: "Also publish to other Nostr video apps (NIP-71)" — off by
default, gated to public + HTTPS-url + public-access-verified videos, and
not offered for
isNsfwvideos whenALLOW_NSFW_CONTENT=false(see "Moderation, web-of-trust, mute/block & NSFW"). - Surface the same control in the My Videos tab (per-video mirror status + toggle; it already lists owned videos with action buttons). Optional bulk "make discoverable" there later.
- Capture dimensions at upload (
loadedmetadataprobe) for 34235-vs-34236 selection +imeta dim; wire the existingdurationthrough. - Add a hashtags input; write
["t", tag]on both the 30078 and the mirror (the feed already scores these — no algo change). - Field conversion on edit/upgrade: map all available 30078 fields into their NIP-71 homes per the mapping table (see "Converting existing videos").
- Publish: 30078 (as today) + the 34235/36 mirror to the user's outbox (NIP-65 write) relays; publish a kind-10002 list first if the user has none (see "Discovery & relays"). Reuse the publish-outcome toast.
- Lifecycle parity: edit updates both (same d-tag); delete publishes a
NIP-09 (
kind 5) referencing the mirror's["a","34235:<pubkey>:<d>"](+k), AND empties the addressable event (empty-replace, the watch-history/encrypted-sync lesson) so clients that ignore NIP-09 still see it cleared; toggle-off does the same for the mirror only. - Mutation-verified lifecycle tests, then flip
FEATURE_PUBLISH_NIP71on.
Phase 1.5 — Discoverability glue (cheap, high interop value)
- NIP-89 handler registration (kind
31990): publish once that bitvid handles video kinds21/22/34235/34236with awebURL template. Result: other clients (Amethyst, etc.) can show "Open in bitvid" when they encounter a video event. - NIP-51 video curation sets (kind
30005): publish/read bitvid playlists as portable video sets so playlists interop across clients. (Sets reference videos bye/atags.)
Phase 2 — Inbound ingest (separate, bigger)
- Feed source querying
34235/36(and21/22) from relays; map viaconvertEventToVideo. - Dedup vs bitvid-native (by
a-address /xhash /url), moderation + trust gating (reuse blacklist / NIP-51 mute), and a distinct discovery surface so foreign videos read as "from the wider Nostrverse," not bitvid-native. - Honor
originfor attribution; respect the no-URL-allowlist rule but apply existing moderation. - (Cross-client comments/zaps via NIP-22
kind:1111addressed to the 34235 event is a further follow-on — bitvid comments currently target 30078.)
Moderation, web-of-trust, mute/block & NSFW (audited 2026-06-22)
The existing controls key on author pubkey and event id, so they apply to NIP-71 videos automatically — as long as ingested videos flow through the same feed pipeline. One real gap (inbound NSFW) + one config-respect (outbound).
Outbound (Phase 1):
- Respect the site
ALLOW_NSFW_CONTENTconfig (config/instance-config.js, defaultfalse): when false, do not offer/allow the mirror forisNsfwvideos — an instance that won't surface NSFW shouldn't publish it outward. The pure builder stays config-agnostic and always setscontent-warningwhen NSFW; gate at the call site. - Mute/block/WoT/admin-blacklist don't newly apply outbound — it's the user's own content (a blacklisted user can't publish anyway).
Inbound (Phase 2) — the real moderation work:
content-warning→isNsfw:mergeNip71MetadataIntoVideodoes NOT currently map a NIP-71content-warningtovideo.isNsfw. The site NSFW filter keys onisNsfw, so foreign NSFW would bypass it. Map it in the ingest path soALLOW_NSFW_CONTENT=falsefilters foreign NSFW too.- Same pipeline, no bypass: route every ingested video through the existing
stages —
blacklist-filter(shouldIncludeVideo: admin/event-id blacklist + blocked authors),moderation(admin whitelist/blacklist + trusted-mute / trusted-report thresholds = web-of-trust), watch-history suppression, kids-audience, NSFW. These key on pubkey/id, so mute lists (NIP-51 kind 10000), blocks, admin lists, and WoT thresholds catch foreign authors automatically. - Trust gate for strangers: don't surface unknown/untrusted foreign authors by default — apply the same trust-seed / web-of-trust gating native videos get (consider requiring author ∈ WoT, or a stricter default threshold for ingest).
- Kids feed is already safe:
kidsAudienceFilterStageis allowlist-based (isForKids !== true⇒ excluded) and also dropsisNsfw+ disallowed content-warnings, so foreign videos (noisForKids) never enter the kids feed.
Enrichment backlog (nice-to-have, not v1)
bitrate, waveform, multiple resolution imeta variants + multi-audio
(ov/language) tracks, text-track (WebVTT captions/subtitles/chapters),
segment (chapters) — all need more captured/transcoded at upload time. v1 ships
spec-valid imeta with url/m/image/dim/duration/x + magnet/i, and
grows from there. (dim/duration are pulled into v1 — see Phase 1.)
Risks / watch-items
- Editing addressable events across foreign clients: some cache aggressively; edits may lag elsewhere. Acceptable; document it.
- Magnet-only catalogs: those videos simply won't appear on non-torrent clients (by design — they still work in bitvid).
- Kind drift: if the ecosystem consolidates on 21/22, add/dual-emit later (builder stays kind-parameterized).
- Ingest safety (Phase 2): arbitrary foreign media + metadata → keep moderation, no autoplay of untrusted sources, sanitize before render.
Sources
- NIP-71 Video Events (current): https://github.com/nostr-protocol/nips/blob/master/71.md (vendored at
docs/nips/71.md) - NIP-92 inline metadata (
imeta): https://github.com/nostr-protocol/nips/blob/master/92.md - NIP-94 file metadata (
magnet,i,x,ox,fallback, …): https://github.com/nostr-protocol/nips/blob/master/94.md - NIP-89 recommended application handlers (
31989/31990): https://github.com/nostr-protocol/nips/blob/master/89.md - NIP-51 lists/sets incl. kind
30005video curation: https://github.com/nostr-protocol/nips/blob/master/51.md - Existing bitvid code:
js/nostr/nip71.js(builders/parser),js/nostrEventSchemas.js(NIP71_VIDEO/NIP71_SHORT_VIDEO),js/constants.js(FEATURE_PUBLISH_NIP71).