Imports not produced by bitvid's storage system (reference imports / external URLs) must be explicitly flagged externally-managed so storage tooling never treats them as bitvid-owned: external badge (never "missing from bucket"), excluded from orphan reconciliation, delete "file left behind" warning suppressed, liveness unverifiable. Adds import modes (full re-host vs reference) + Phase 2b to the plan and TODO 17c. Notes the existing isUrlUnderBase heuristic is the baseline but a durable flag is needed. Planning only; no code changes. Co-Authored-By: Claude Opus 4.8 <[email protected]>
9.2 KiB
NIP-71 On-Boarding + Cross-Ecosystem Dedup — Dev Plan
Status: planned (not started). Owner: TBD. Branch: unstable.
Companion to docs/nip71-migration-plan.md (off-boarding / mirror / ingest, all shipped).
Goal
Make NIP-71 interop a two-way street:
- Off-boarding (DONE): a bitvid video (kind 30078) is mirrored out to NIP-71 (addressable 34235/36) so other apps can discover it.
- On-boarding (THIS PLAN): a creator's NIP-71 video published via another app can be pulled into bitvid so it gains bitvid's advantages — CDN-hosted URL, a WebTorrent source, and (optionally) bitvid extras.
And the invariant that makes both safe to coexist:
- One video, never two. When the same video exists in both ecosystems, always prefer the bitvid (kind-30078) version, show exactly one, in every grid/list/scenario. Never both.
The hard constraint (why on-boarding is creator-initiated)
Off-boarding works because the bitvid creator owns the video and signs BOTH events with their own key. On-boarding is not symmetric: a foreign NIP-71 video is signed by the foreign creator's key, and bitvid cannot forge a kind-30078 note authored by someone else (no private key). Therefore:
- On-boarding = creator-initiated self-import only. A creator who published NIP-71 videos elsewhere logs into bitvid with the same npub, and bitvid offers to import them: re-host the file to their own connected storage (CDN), optionally seed WebTorrent, and publish a bitvid kind-30078 version signed by their key. No auto-rehosting of other people's files (consent / cost / liability). This mirrors off-boarding exactly: both directions author-owned and consensual.
Locked decisions (from design discussion)
- On-boarding model: A — creator-initiated self-import.
- Dedup identity: explicit import-link + content hash (
fileSha256/ox) + infohash (i/magnet btih). NO fuzzy url/title matching (avoids false merges). Note: hash-less foreign videos (e.g. url+image only) won't dedup unless they were on-boarded (which adds an explicit link) — acceptable. - Advantages on import: CDN url + WebTorrent source (baseline). bitvid extras (comments, view counts, edit-in-place, private toggle) come free once it's a real 30078 note; no extra work.
- Cadence: one-shot import with a re-run button (not continuous lockstep). Re-running re-imports anything new.
Phasing — dedup FIRST (it's needed regardless of on-boarding)
Phase 1 — Cross-ecosystem dedup ("prefer bitvid, show one, everywhere")
Why first: even today, a creator who independently posts to bitvid AND Nostube can produce two cards. And the moment on-boarding exists, the original + imported versions both exist on relays. Dedup must land before/with on-boarding.
- Identity key:
author + (importLink ‖ fileSha256 ‖ ox ‖ infoHash).importLink: pointer an on-boarded 30078 carries to the original NIP-71 event/address (added in Phase 2). Most reliable.- Hashes/infohash already extracted by
convertEventToVideo(30078) andbuildVideoFromNip71Event(foreign) — confirm both populatefileSha256/originalFileSha256/infoHash.
- Preference: within an identity group, keep the bitvid version
(
source !== "nip71-ingest"/ kind 30078); else keep the NIP-71 one. - Placement (one chokepoint → all grids): the feed AND channel profile already
call
app.dedupeVideosByRoot(...). Add a purecollapseCrossEcosystem(videos)pass and either chain it after root-dedup inside that shared helper, or wrap both into onededupeVideos(videos)used everywhere (feed enginecreateDedupeByRootStage,buildRenderableChannelVideos, My Videos, search, playlists, watch history). Audit every list builder for the shared call. - Bitvid's own outbound mirrors are already skipped at ingest
(
client=bitvid+aback-pointer) — keep that; this pass handles the rest. - Tests (cheat-resistant, pure): same-hash bitvid+foreign → only bitvid kept; foreign-only (no bitvid) → foreign kept; distinct videos not merged; hash-less pair NOT merged (documented limitation); import-link match → original hidden. Mutation-verify the prefer-bitvid selection and the identity grouping.
Phase 2 — Creator-initiated on-board import
-
Discovery: on login, find the logged-in creator's own NIP-71 videos (reuse the ingest subscription/adapter, scoped to
authors:[viewerPubkey]) that have no corresponding bitvid 30078 yet (via the Phase-1 identity key). -
UI: a "Import your videos from other apps" surface (My Videos pane or a one-time prompt): list importable items, let the creator pick, show what import does. Requires their storage connected (re-host target) and signer.
-
Import action (per video), reusing existing plumbing:
- Fetch the source file from the foreign
url(or resolve via magnet). - Re-host to the creator's connected storage (reuse
storageService/r2Serviceupload path used by normal uploads) → bitvid CDN url. - Optionally create a
.torrent+ WebTorrent magnet (reuse upload's torrent path) so the video gets a P2P source. - Build + publish a kind-30078 note signed by the creator (reuse
videoPublisher/buildVideoPostEvent), carrying: title, description, hashtags, dims/duration (from the foreign imeta via the adapter), the new CDN url + magnet, AND an explicit["imported-from", "<orig event id / a-addr>"]provenance tag for dedup + attribution. - (Optional, symmetric) since it's now a normal bitvid video, the existing outbound mirror/auto-share can keep the NIP-71 side in lockstep going forward.
- Fetch the source file from the foreign
-
Import modes (decision): offer both
- Full import (re-host): file copied to the creator's storage → bitvid-managed (CDN url under their bucket), normal storage tooling applies.
- Reference import (no re-host): publish a 30078 that keeps the external URL (lighter, no storage cost). The video is now a bitvid note but its file is externally managed — see storage-provenance below.
-
Result: the bitvid 30078 is now canonical; Phase-1 dedup hides the original foreign event everywhere; the creator gets bitvid features + (full import) CDN + WebTorrent, or (reference import) discovery without re-hosting.
Storage provenance — flag externally-managed imports
Any imported (or otherwise bitvid-converted) video whose file was not produced by our storage system must be explicitly flagged as externally managed so the storage tooling never treats it as a bitvid-owned object.
- Today:
isUrlUnderBase(url, publicBaseUrl)(myVideosHealth.js) already classifies bucket vs external ("Hosted URL" vs "External URL"), andreconcileStorageonly matches files under the base. So a reference-imported external URL is mostly handled by the heuristic. - Add (durable + explicit): stamp the on-boarded note as externally managed
rather than relying solely on the URL-vs-base heuristic (which breaks if the
creator changes their storage base, or an external host coincidentally matches).
Carry it on the video object (e.g.
externalStorage: true, derived from theimported-from/reference-import path) and, where appropriate, on the event. - All storage surfaces must honor it:
- My Videos health: show an "External URL / externally managed" badge; never flag as missing-from-bucket.
- Orphan reconciliation: exclude — never list as an orphan, never offer bucket delete for a file we don't own.
- Delete flow: suppress the "a hosted file is left behind" storage-cleanup warning for externally-managed videos (there's nothing in our bucket to clean).
- Liveness: external URL stays "unverifiable" (existing behavior).
- Full import sets
externalStorage: false(it IS in the creator's bucket) and behaves like any native upload. - Guards:
- Only the same-pubkey creator can import their own videos (signer == author).
- Never import NSFW outward against
ALLOW_NSFW_CONTENT(mirror the existing gate); carrycontent-warning → isNsfw. - Re-host uses the creator's storage only; surface cost/ownership in the copy.
- One-shot; a "re-scan / import new" button re-runs discovery.
Non-goals / future
- No instance-level auto-rehosting of arbitrary creators' files (model B) — consent/ cost/liability. Revisit only with explicit per-instance policy + creator opt-in.
- No fuzzy (url/title) dedup — revisit only if hash-less dual-posts become a real problem.
- Continuous lockstep on the inbound side (re-import on the creator editing the original elsewhere) — deferred; one-shot + re-run is enough for launch.
Reuse map (existing code to lean on)
js/nostr/nip71IngestAdapter.js— foreign event → video (hashes, dims, imeta).js/nostr/nip71Mirror.js— outbound mirror (keeps NIP-71 side in lockstep after import).js/services/nip71IngestService.js— author-scoped subscription pattern for discovery.js/channelProfileVideos.js, feedcreateDedupeByRootStage,app.dedupeVideosByRoot— the dedup chokepoints to extend.storageService/r2Service/ upload + torrent paths — re-host + seed.videoPublisher/buildVideoPostEvent— publish the signed 30078.