Promote unstable → beta: NIP-71 ingest/dedup/channel-profile, multi-source liveness, cold-start REQ batching, from_hex sanitizer, zap LNURL error

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-22 22:28:23 -04:00
commit 9937c381b2
50 files changed
+4914 -79

No files matched your search

+4
View File
@@ -1028,6 +1028,10 @@
role="status"
aria-live="polite"
></p>
<label id="profileMyVideosAutoShareRow" class="hidden flex items-start gap-2 text-sm text-muted-strong">
<input id="profileMyVideosAutoShare" type="checkbox" class="checkbox mt-0.5" />
<span>Auto-share new public videos to other Nostr apps (NIP-71)</span>
</label>
<p
id="profileMyVideosLoading"
class="text-sm text-muted hidden"
+15
View File
@@ -234,6 +234,21 @@ export const DEFAULT_WHITELIST_MODE_ENABLED = true;
*/
export const ALLOW_NSFW_CONTENT = false;
/**
* Whether the client ingests NIP-71 video events published by *other* Nostr
* apps (kinds 21/22 and addressable 34235/34236) and surfaces them in bitvid's
* feeds.
*
* Defaults to `true` so bitvid discovers videos from the wider Nostr ecosystem
* (Nostube, Flotilla, etc.). bitvid's own outbound mirrors are skipped to avoid
* duplicate listings, and ingested NSFW content still respects
* `ALLOW_NSFW_CONTENT` plus the standard trust/blacklist/mute gating.
*
* Set to `false` to disable all inbound NIP-71 ingest with a single switch if it
* ever causes problems (spam, low-quality content, performance).
*/
export const FEATURE_NIP71_INGEST = true;
/**
* Emergency fallback accounts for seeding the moderation graph.
*
+69
View File
@@ -12,3 +12,72 @@
- Keep source HTML entry points (`index.html`, `embed.html`) pointing at logical asset paths (for example, `js/index.js` and `css/tailwind.generated.css`). The build pipeline rewrites those references to content-hashed filenames in `dist/`.
- Deploy pipelines should always purge HTML entrypoints (`/` + `/index.html` and `/embed.html`) at the edge after publish so clients do not keep serving stale source shells.
- App routing query parameters (such as `?v=` for video pointers) are unchanged and remain part of runtime URL behavior.
## Release channels → Vercel projects (promotion + on-demand deploy)
The three release branches each map to a **separate Vercel project** under the
`pr0m3th3ans-projects` team:
| Branch | Vercel project | Live URL |
|------------|-------------------|---------------------------|
| `unstable` | `bitvid-unstable` | `unstable.bitvid.network` |
| `beta` | `bitvid-beta` | `bitvid-beta.vercel.app` |
| `main` | `bitvid` | `bitvid.network` (prod) |
The repo's local `.vercel/project.json` is normally linked to **`bitvid-unstable`**
so day-to-day `vercel deploy --prod` targets the unstable site.
### vercel.json knobs
- `installCommand: "npm install"` is pinned because both `package-lock.json` and
`pnpm-lock.yaml` exist; without the pin Vercel auto-selects pnpm and the build
fails on the unstable/beta projects.
- `git.deploymentEnabled.unstable: false` disables auto-deploy for a branch named
`unstable`. It only affects that branch key, so it's harmless on the beta/main
projects (they deploy `beta`/`main`). The bitvid-beta/bitvid projects ARE
git-connected, so a push to `beta`/`main` also auto-triggers a build — explicit
CLI deploys (below) just make the result deterministic.
### Promotion (unstable → beta → main)
`unstable` is the integration branch. `main` is usually a clean ancestor
(fast-forward). `beta` can carry an extra superseded commit (e.g. an old
`vercel.json` pin), so prefer a merge that takes unstable's tree:
```bash
# beta: merge unstable, preferring unstable on conflict; verify trees match
git checkout beta && git reset --hard origin/beta
git merge origin/unstable -X theirs --no-edit -m "Promote unstable → beta: <summary>"
git diff --quiet origin/unstable HEAD && echo "beta tree == unstable" # expect 0
git push origin beta
# main: fast-forward to unstable
git checkout main && git reset --hard origin/main
git merge --ff-only origin/unstable
git push origin main
```
### Build/deploy each project (on-demand)
```bash
# beta
vercel link --yes --project bitvid-beta && vercel deploy --prod --yes
# production
vercel link --yes --project bitvid && vercel deploy --prod --yes # aliases to bitvid.network
# restore the day-to-day link
vercel link --yes --project bitvid-unstable
```
### Verify
The footer version marker (`v: <8hex> • <date>`) is content-hash based; all three
projects build from the same source in the same Vercel env, so the markers should
**match each other** after a promotion. Verify per domain and confirm a known new
module is served, e.g.:
```bash
for host in unstable.bitvid.network bitvid-beta.vercel.app bitvid.network; do
echo -n "$host : "; curl -s "https://$host/index.html?cb=$(date +%s)" \
| grep -oE 'v: [0-9a-f]{8} • [0-9-]+' | head -1
done
```
> Caveat: promoting a large batch straight to `main` skips the `beta` soak. Do it
> only deliberately; otherwise let changes soak on `beta` first. Rollback = redeploy
> the previous `bitvid` production deployment (`vercel rollback` or redeploy by URL).
+295
View File
@@ -0,0 +1,295 @@
# 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)
1. **Kind: addressable `34235` (normal) / `34236` (short).**
2. **Opt-in, off by default** — per-video toggle; private videos never mirrored.
3. **Scope: outbound first, inbound later** (Phase 1 vs Phase 2).
4. **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:d` is 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` … Kind `34236` … include a `d` tag 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` (`nip71Cache` already 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:**
1. **Private videos are never mirrored.** NIP-71 has no encryption; mirroring would
leak them. The toggle is disabled (with explanation) for private videos.
2. **A hosted HTTPS `url` is required to mirror.** Foreign clients can't play a
magnet-only video; bitvid's magnet still rides along (`magnet`/`i`) for
torrent-aware clients, but `url` must be present or the toggle is disabled.
3. **The `url` must 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), and `publishVideo` targets `this.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.list` directly.
---
## Phases
### Phase 0 — Retarget builders (no UX; flag stays off)
- Point existing `buildNip71VideoEvent` at **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 `isNsfw` videos when `ALLOW_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** (`loadedmetadata` probe) for 34235-vs-34236
selection + `imeta dim`; wire the existing `duration` through.
- **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_NIP71` on.
### Phase 1.5 — Discoverability glue (cheap, high interop value)
- **NIP-89 handler registration** (kind `31990`): publish once that bitvid handles
video kinds `21/22/34235/34236` with a `web` URL 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
by `e`/`a` tags.)
### Phase 2 — Inbound ingest (separate, bigger)
- Feed source querying `34235/36` (and `21/22`) from relays; map via
`convertEventToVideo`.
- Dedup vs bitvid-native (by `a`-address / `x` hash / `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 `origin` for attribution; respect the no-URL-allowlist rule but apply
existing moderation.
- (Cross-client comments/zaps via NIP-22 `kind:1111` addressed 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_CONTENT` config (`config/instance-config.js`,
default `false`): when false, **do not offer/allow the mirror for `isNsfw`
videos** — an instance that won't surface NSFW shouldn't publish it outward.
The pure builder stays config-agnostic and always sets `content-warning` when
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`**: `mergeNip71MetadataIntoVideo` does NOT
currently map a NIP-71 `content-warning` to `video.isNsfw`. The site NSFW
filter keys on `isNsfw`, so foreign NSFW would bypass it. Map it in the ingest
path so `ALLOW_NSFW_CONTENT=false` filters 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**: `kidsAudienceFilterStage` is allowlist-based
(`isForKids !== true` ⇒ excluded) and also drops `isNsfw` + disallowed
content-warnings, so foreign videos (no `isForKids`) 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 `30005` video 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`).
+160
View File
@@ -0,0 +1,160 @@
# 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**:
1. **Off-boarding (DONE):** a bitvid video (kind 30078) is mirrored *out* to NIP-71
(addressable 34235/36) so other apps can discover it.
2. **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:
3. **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) and
`buildVideoFromNip71Event` (foreign) — confirm both populate `fileSha256` /
`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 pure `collapseCrossEcosystem(videos)`
pass and either chain it after root-dedup inside that shared helper, or wrap
both into one `dedupeVideos(videos)` used everywhere (feed engine
`createDedupeByRootStage`, `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` + `a` back-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:**
1. Fetch the source file from the foreign `url` (or resolve via magnet).
2. Re-host to the creator's connected storage (reuse `storageService` /
`r2Service` upload path used by normal uploads) → bitvid CDN url.
3. Optionally create a `.torrent` + WebTorrent magnet (reuse upload's torrent
path) so the video gets a P2P source.
4. 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.
5. (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.
- **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"), and
`reconcileStorage` only 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 the
`imported-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); carry `content-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`, feed `createDedupeByRootStage`, `app.dedupeVideosByRoot`
— the dedup chokepoints to extend.
- `storageService` / `r2Service` / upload + torrent paths — re-host + seed.
- `videoPublisher` / `buildVideoPostEvent` — publish the signed 30078.
+99 -3
View File
@@ -20,16 +20,34 @@ Nothing except cavaliership and common sense prevents a _short_ video from being
The format uses a _regular event_ kind `21` for _normal_ videos and `22` for _short_ videos.
## Addressable Video Events
For content that may need updates after publication (such as correcting metadata, descriptions, or handling URL migrations), addressable versions are available:
- Kind `34235` for _addressable normal videos_
- Kind `34236` for _addressable short videos_
These addressable events follow the same format as their regular counterparts but include a `d` tag as a unique identifier and can be updated while maintaining the same addressable reference. This is particularly useful for:
- Metadata corrections (descriptions, titles, tags) without republishing
- Preservation of imported content IDs from legacy platforms
- URL migration when hosting changes
- Platform migration tracking
The `.content` of these events is a summary or description on the video content.
The primary source of video information is the `imeta` tags which is defined in [NIP-92](92.md)
Each `imeta` tag can be used to specify a variant of the video by the `dim` & `m` properties.
Each `imeta` tag can be used to specify a variant of the video by the `dim` & `m` properties, as well as multiple audio tracks (languages).
This NIP defines the following additional `imeta` properties aside form those listen in [NIP-92](92.md) & [NIP-94](94.md):
Clients should check if audio tracks are available and prefer them over in-video audio
to allow smooth resolution switching without interrupting audio. The `ov` (original version) flags the original language track.
This NIP defines the following additional `imeta` properties aside from those listed in [NIP-92](92.md) & [NIP-94](94.md):
* `duration` (recommended) the duration of the video/audio in seconds (floating point number)
* `bitrate` (recommended) the average bitrate of the video/audio in bits/sec
* `waveform` (optional, audio only) amplitude values over time, space separated full integers, less than 100 values should be enough to render a nice visual
Example:
```json
@@ -72,6 +90,18 @@ Example:
"service nip96",
"duration 29.21"
],
["imeta",
"url https://myaudio.com/audio/en/12345.mp3",
"x b2e0a7a82ac9f3f3a71f1d9a78c381d5be9d1cf19dce258765c17c8a76287c93",
"m audio/mp3",
"waveform 0 7 35 8 100 100 49 8 4 16 8 10 7 2 20 10 100 100 100 100 100 100 15 100 100 100 25 60 5 4 3 1 0 100 100 15 100 29 88 0 33 11 39 100 100 19 4 100 42 35 5 0 1 5 0 0 11 38 100 94 17 11 44 58 5 100 100 100 55 14 72 100 100 57 6 1 14 2 16 100 100 40 16 100 100 6 32 14 13 41 36 16 14 6 3 0 1 2 1 6 0",
"l en ISO-639-1 ov",
"fallback https://myotherserver.com/audio/en/12345.mp3",
"fallback https://andanotherserver.com/audio/en/12345.mp3",
"service nip96",
"bitrate 320000",
"duration 29.24"
],
]
```
@@ -81,6 +111,9 @@ The `image` tag contains a preview image (at the same resolution). Multiple `ima
Additionally `service nip96` may be included to allow clients to search the authors NIP-96 server list to find the file using the hash.
### Required tags for addressable events:
* `d` - Unique identifier for this video (user-chosen string, required for kinds 34235, 34236)
### Other tags:
* `title` (required) title of the video
* `published_at`, for the timestamp in unix seconds (stringified) of the first time the video was published
@@ -92,7 +125,10 @@ Additionally `service nip96` may be included to allow clients to search the auth
* `p` (optional, repeated) 32-bytes hex pubkey of a participant in the video, optional recommended relay URL
* `r` (optional, repeated) references / links to web pages
```jsonc
### Optional tags for imported content:
* `origin` - Track original platform and ID: `["origin", "<platform>", "<external-id>", "<original-url>", "<optional-metadata>"]`
```yaml
{
"id": "<32-bytes lowercase hex-encoded SHA-256 of the the serialized event data>",
"pubkey": "<32-bytes lowercase hex-encoded public key of the event creator>",
@@ -135,3 +171,63 @@ Additionally `service nip96` may be included to allow clients to search the auth
]
}
```
## Addressable Event Example
```yaml
{
"id": <32-bytes lowercase hex-encoded SHA-256 of the the serialized event data>,
"pubkey": <32-bytes lowercase hex-encoded public key of the event creator>,
"created_at": <Unix timestamp in seconds>,
"kind": 34235 | 34236,
"content": "<summary / description of video>",
"tags": [
["d", "<unique-identifier>"],
["title", "<title of video>"],
["published_at", "<unix timestamp>"],
["alt", "<description for accessibility>"],
// video data
["imeta",
"url https://example.com/media.mp4",
"m video/mp4",
"dim 480x480",
"blurhash eVF$^OI:${M{%LRjWBoLoLaeR*",
"image https://example.com/thumb.jpg",
"x 3093509d1e0bc604ff60cb9286f4cd7c781553bc8991937befaacfdc28ec5cdc"
],
// audio data
["imeta",
"url https://example.com/audio.mp3",
"x b2e0a7a82ac9f3f3a71f1d9a78c381d5be9d1cf19dce258765c17c8a76287c93",
"m audio/mp3",
"l en ISO-639-1 ov"
],
["duration", <duration in seconds>],
["content-warning", "<reason>"],
// origin tracking for imported content
["origin", "<platform>", "<external-id>", "<original-url>", "<optional-metadata>"],
// participants
["p", "<32-bytes hex of a pubkey>", "<optional recommended relay URL>"],
// hashtags
["t", "<tag>"],
["t", "<tag>"],
// reference links
["r", "<url>"]
]
}
```
## Referencing Addressable Events
To reference an addressable video:
```
["a", "34235:<pubkey>:<d-tag-value>", "<relay-url>"] // for normal videos
["a", "34236:<pubkey>:<d-tag-value>", "<relay-url>"] // for short videos
```
+73
View File
@@ -0,0 +1,73 @@
# Video Liveness / Card Health — Dev Plan
Status: **planned** (investigated; not started). Branch: `unstable`.
## Goal
Cards should only render videos that can actually play, decide fast, support
multiple source links (fall back if one is down), and never show two of the same
video (handled separately by cross-ecosystem dedup). Prefer CDN; if CDN is dead
but a WebTorrent source is live, keep the card.
## What already works (don't rebuild)
- **Hide/show policy is correct** (`js/utils/cardSourceVisibility.js`): a card
hides only when **neither** CDN (`urlHealthState`) **nor** WebTorrent
(`streamHealthState`) is healthy (and neither is pending); it un-hides the
moment WebTorrent flips `healthy`. This already matches "no CDN → block; if a
webtorrent flips green → bring it back."
- Owner's own videos bypass hiding.
## The real problems (diagnosis)
1. **Too slow.** `URL_PROBE_TIMEOUT_MS = 8s` (+ a retry), WebTorrent
`PROBE_TIMEOUT_MS = 20s`. While a probe is *pending*, the card is **shown**
(pending ≠ hidden). So unplayable videos sit visible for many seconds before
being hidden. → tune timeouts + concurrency for a faster verdict.
2. **Probe accuracy.** The CDN check races a `HEAD {mode:"no-cors"}` which is
**opaque** (status unreadable) for cross-origin hosts (most NIP-71 CDNs like
`relay.towardsliberty.com`). Opaque without `confirmPlayable` → `unknown` (not
healthy). Whether a card shows then hinges on timing/streamState. Net: foreign
CDNs are judged unreliably. → use the **video-element probe** (`confirmPlayable`,
actually loads the media) as the source of truth for the card verdict.
3. **Single source only.** The ingest adapter keeps ONE `url` (the primary imeta
variant) and drops the rest. NIP-71 events can list **multiple imeta** (mirrors
/ alternate media). Example: Walker/THE Bitcoin Podcast event has a `video/mp4`
imeta AND an `audio/mpeg` imeta; other events may list multiple video mirrors.
→ capture all playable sources and fall back across them.
## Plan
### Phase 1 — Multi-source capture (adapter + video model)
- `buildVideoFromNip71Event` (and the 30078 path where applicable) should expose a
`sources` list: every imeta `url` whose `m` is a video type (and the magnet),
in order, each with `{ url, mimeType, sha256, dim, duration }`. Keep the primary
`url`/`magnet` for back-compat; add `sources` for fallback.
- Skip non-video media (e.g. `audio/mpeg`) for the *video* player, but we may keep
audio as a separate affordance later (out of scope).
### Phase 2 — Liveness uses real playability across sources
- The card URL probe should try sources **in order** via the video-element probe
(`confirmPlayable`); `healthy` if ANY source loads; `offline` only if ALL fail.
- Cache per-source results (already have a probe cache) so scrolling doesn't
re-probe.
- Player playback should consume the same ordered `sources` so the one that
probed healthy is what plays (and can fail over at play time too).
### Phase 3 — Speed
- Lower `URL_PROBE_TIMEOUT_MS` (8s → ~3–4s) and the video-element probe timeout;
keep one retry only for `timeout` (not `error`). Consider bumping URL-probe
concurrency (it's cheap vs WebTorrent swarm joins).
- WebTorrent `PROBE_TIMEOUT_MS` (20s): keep generous (swarm joins are slow) but
ensure it runs in parallel and never blocks the CDN verdict.
- DECISION NEEDED: **show-pending (current) vs hide-until-verified.** Showing
pending means a brief flash of soon-to-be-hidden dead cards; hide-until-verified
means good cards pop in after the probe. Recommend: keep show-pending for cards
with a *bitvid-native/whitelisted* source (fast/trusted), but **hide-until-verified
for foreign/ingested** videos (so unplayable strangers never flash in).
## Reuse map
- `js/ui/urlHealthController.js` (probeUrl / probeUrlWithVideoElement) — make
it iterate sources + prefer the video-element verdict.
- `js/gridHealth.js` (WebTorrent swarm probe) — timing only.
- `js/utils/cardSourceVisibility.js` — policy already correct; no change expected.
- `js/nostr/nip71IngestAdapter.js` — add `sources`.
- `js/constants.js` — `URL_PROBE_TIMEOUT_MS`, probe concurrency.
- Player/playback service — consume `sources` for fail-over.
+62
View File
@@ -0,0 +1,62 @@
# Zap System Audit — Plan & Findings
Status: **in progress** (audit started 2026-06-23). Branch: `unstable`.
Pre-launch priority #3. The zap flow feels clunky and errors on send.
## Architecture (map for the next session)
- **Two controllers**: `js/ui/components/video-modal/zapController.js` (the in-modal
UI — button, popover, comment/amount inputs; dispatches `video:zap` / `zap:open`)
and `js/ui/zapController.js` (the logic — `open()`, `sendZap()`, retry, receipts).
`ModalManager` wires `video:zap → zapController.sendZap`.
- **Payment flow**: `sendZap` → `splitAndZap` (`js/payments/zapSplit.js`) → per share
(`creator`, `platform`) `processShare` → `lnurl.fetchPayServiceData` →
`buildZapRequest` (kind 9734, comment in `content`) → `lnurl.requestInvoice`
(`nostr=` zapRequest + optional `comment=` param) → `wallet.sendPayment` (NWC,
`nwcClient.js`) → `zapReceiptValidator` (kind 9735).
- **Fee split**: `PLATFORM_FEE_PERCENT` (instance-config, currently 30) →
`platformShare = floor(amount*fee/100)`, `creatorShare = amount - platformShare`;
platform address via `platformAddress.js` / `PLATFORM_LUD16_OVERRIDE`.
## Findings so far
- **Send error = recipient LNURL unreachable (CORS/offline).** [PARTLY FIXED]
Sending to a creator whose LNURL host is down or lacks CORS headers (observed:
`unitypay.cash/.well-known/lnurlp/goblinbox`) makes the browser `fetch` reject
with "Failed to fetch". Shipped (`dd1ce113`): `fetchPayServiceData` now throws a
clear, coded (`lnurl-unreachable`) message. **Root issue remains**: a static
client CANNOT fetch CORS-less LNURL hosts — see "CORS proxy" below.
- **Comment box works.** Confirmed via screenshot — the textarea renders and binds;
the comment is carried in the zap-request `content` + the LNURL `comment` param.
The earlier "message doesn't work" was the popover being mis-positioned/hard to
use (see 3a), not a comment bug.
- **Popover mis-positioning (zap + embed)** — see TODO 3a; shared `popoverEngine`.
## To do (next session, in priority order)
1. **CORS / LNURL proxy decision (the real reliability fix).** A static client
can't fetch LNURL endpoints that omit CORS headers, so zaps to many creators
fail. Options: (a) a small server-side/edge proxy (Vercel function) that fetches
the LNURL pay-service data + invoice and returns it with CORS — what most web
wallets do; (b) document the limitation and only support CORS-enabled hosts;
(c) route via the connected NWC wallet if it can resolve LN addresses. Decide,
then implement. Confirm whether `unitypay.cash` is also just offline.
2. **Popover positioning (3a).** Fix the shared engine safely (portal in-modal
panels out to `#uiOverlay`, or remove the modal-subtree containing block).
Needs the live computed `position/left/top` + DOM parent of `#modalZapDialog`.
VERIFY non-modal popovers still position right after.
3. **Platform-fee split correctness.** Verify recipients/percentages/rounding;
ensure the fee can't swallow the whole zap, be bypassed, or (earlier report)
land the platform fee in the *sender's* own wallet when zapping certain targets.
Check the self-zap / creator==platform edge case. Add cheat-resistant tests.
4. **Receipt validation (kind 9735).** Confirm accuracy before crediting; the
`unvalidatedReceipts`/`failureReceipts` paths and the "Awaiting validated zap
receipt" state. (Earlier fix: `zapReceiptValidator` SimplePool `.list` shim.)
5. **NWC error UX.** "Budget exceeded" and partial-share retry flow — make the
messaging + retry obviously usable (it's currently terse).
6. **General clunkiness.** Step back on the whole flow: amount presets, the
split summary, success/partial/failure states, focus, and mobile.
## Cheat-resistant testing notes
- `fetchPayServiceData` / `requestInvoice` accept an injectable `fetcher` — test
failure modes by injecting rejecting/erroring fetchers (see
`tests/lnurl-fetch-error.test.mjs`).
- `splitAndZap` takes injectable `dependencies` (see `tests/zap-split.test.mjs`) —
use it to assert fee math, rounding, and recipient routing without real LN.
+6
View File
@@ -492,6 +492,12 @@ class AccessControl {
return Array.from(this.whitelist);
}
// Hex-encoded whitelist pubkeys (resolved from npubs). Used by NIP-71 ingest
// to scope its relay subscription to authors this instance already allows.
getWhitelistPubkeys() {
return Array.from(this.whitelistPubkeys);
}
getBlacklist() {
return Array.from(this.blacklist);
}
+88
View File
@@ -0,0 +1,88 @@
// Pure helpers for batching the community-blacklist curator fetch into a single
// multi-author REQ instead of one kind-30000 REQ per curator (the cold-start
// relay storm). Kept dependency-free so they're cheat-resistant to test.
// Build a single batched filter for many (authorHex, dTag) references. A Nostr
// filter ORs `authors` and ORs `#d`, so one REQ replaces N per-curator REQs.
// Over-fetches author×dtag cross-products, which selectNewestEventsForReferences
// discards when matching back to exact references.
export function buildBatchedReferenceFilter(references, { kind = 30000 } = {}) {
const authors = new Set();
const dTags = new Set();
for (const ref of Array.isArray(references) ? references : []) {
const author =
typeof ref?.authorHex === "string" ? ref.authorHex.trim().toLowerCase() : "";
const dTag = typeof ref?.dTag === "string" ? ref.dTag.trim() : "";
if (author) authors.add(author);
if (dTag) dTags.add(dTag);
}
if (!authors.size || !dTags.size) {
return null;
}
return {
kinds: [kind],
authors: Array.from(authors),
"#d": Array.from(dTags),
};
}
function dTagOf(event) {
const tags = Array.isArray(event?.tags) ? event.tags : [];
for (const tag of tags) {
if (Array.isArray(tag) && tag[0] === "d") {
return typeof tag[1] === "string" ? tag[1] : "";
}
}
return "";
}
// Group batched events to the newest per (author, dTag), then return the events
// matching the requested references (in reference order). Cross-product events
// (author×dtag combinations that weren't requested) are discarded.
export function selectNewestEventsForReferences(events, references) {
const newest = new Map(); // key `${pubkey}:${dtag}` -> event
for (const event of Array.isArray(events) ? events : []) {
const pubkey = typeof event?.pubkey === "string" ? event.pubkey.toLowerCase() : "";
const dTag = dTagOf(event);
if (!pubkey || !dTag) {
continue;
}
const key = `${pubkey}:${dTag}`;
const prev = newest.get(key);
if (
!prev ||
event.created_at > prev.created_at ||
(event.created_at === prev.created_at && event.id > prev.id)
) {
newest.set(key, event);
}
}
const matched = [];
for (const ref of Array.isArray(references) ? references : []) {
const author = typeof ref?.authorHex === "string" ? ref.authorHex.toLowerCase() : "";
const dTag = typeof ref?.dTag === "string" ? ref.dTag : "";
const event = newest.get(`${author}:${dTag}`);
if (event) {
matched.push(event);
}
}
return matched;
}
// Fetch all curator blacklists in ONE batched REQ and return the newest event
// matched to each reference. `listEvents` is injected (the subscription
// manager's health-gated, timeout-bounded list) so this stays off the raw pool.
// Returns [] when there's nothing to batch; rejects on fetch failure.
export async function fetchBatchedReferenceEvents({
references,
listEvents,
relays,
timeoutMs,
}) {
const filter = buildBatchedReferenceFilter(references);
if (!filter || typeof listEvents !== "function") {
return [];
}
const events = await listEvents({ filters: [filter], relays, timeoutMs });
return selectNewestEventsForReferences(events, references);
}
+19 -19
View File
@@ -22,6 +22,7 @@ import {
} from "./nostrEventSchemas.js";
import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js";
import { publishEventToRelay } from "./nostrPublish.js";
import { fetchBatchedReferenceEvents } from "./adminListBatch.js";
const ADMIN_STATE_CACHE_VERSION = 1;
const ADMIN_STATE_CACHE_KEY = `bitvid_admin_state_v${ADMIN_STATE_CACHE_VERSION}`;
@@ -688,25 +689,24 @@ async function loadCommunityBlacklistEntries() {
return [];
}
const results = await Promise.all(
references.map(async (reference) => {
try {
const event = await fetchLatestListEvent(
{ "#d": [reference.dTag], authors: [reference.authorHex] },
reference.dTag,
);
return event ? extractNpubsFromEvent(event) : [];
} catch (error) {
devLogger.warn(
`[adminListStore] Failed to load community blacklist ${reference.dTag}:`,
error,
);
return [];
}
})
);
return results.flat();
// Batch all curator blacklists into ONE REQ instead of one-per-curator — this
// was the cold-start kind-30000 relay storm. Best-effort: on failure we return
// nothing and the cached admin state (readCachedAdminState) carries over.
try {
const matched = await fetchBatchedReferenceEvents({
references,
listEvents: (args) => requireNostrClient().getSubscriptionManager().list(args),
relays: ensureNostrReady(),
timeoutMs: SHORT_TIMEOUT_MS,
});
return matched.flatMap((event) => extractNpubsFromEvent(event));
} catch (error) {
devLogger.warn(
"[adminListStore] Batched community blacklist fetch failed:",
error,
);
return [];
}
}
function buildListEvent(listKey, npubs, actorHex) {
+2 -1
View File
@@ -135,7 +135,7 @@ import resolveVideoPointer, {
buildVideoAddressPointer,
} from "./utils/videoPointer.js";
import { isValidMagnetUri } from "./utils/magnetValidators.js";
import { dedupeToNewestByRoot } from "./utils/videoDeduper.js";
import { dedupeToNewestByRoot, dedupeVideos } from "./utils/videoDeduper.js";
import { buildServiceWorkerFallbackStatus } from "./utils/serviceWorkerFallbackMessages.js";
import { batchFetchProfilesFromRelays } from "./utils/profileBatchFetcher.js";
import {
@@ -633,6 +633,7 @@ class Application {
fetchProfileMetadata,
ensureProfileMetadataSubscription,
dedupeToNewestByRoot,
dedupeVideos,
buildServiceWorkerFallbackStatus,
sanitizeProfileMediaUrl,
UNSUPPORTED_BTITH_MESSAGE,
+6 -1
View File
@@ -56,6 +56,7 @@ export function createPlaybackCoordinator(deps) {
fetchProfileMetadata,
ensureProfileMetadataSubscription,
dedupeToNewestByRoot,
dedupeVideos,
buildServiceWorkerFallbackStatus,
sanitizeProfileMediaUrl,
UNSUPPORTED_BTITH_MESSAGE,
@@ -216,7 +217,11 @@ export function createPlaybackCoordinator(deps) {
if (!Array.isArray(videos) || videos.length === 0) {
return [];
}
return dedupeToNewestByRoot(videos);
// Root-dedupe AND collapse cross-ecosystem duplicates (prefer bitvid). This
// is the shared chokepoint for the feed stage + channel/search grids.
return typeof dedupeVideos === "function"
? dedupeVideos(videos)
: dedupeToNewestByRoot(videos);
},
autoplayModalVideo() {
+19 -19
View File
@@ -1,7 +1,8 @@
// js/channelProfile.js
import { nostrClient } from "./nostrClientFacade.js";
import { convertEventToVideo as sharedConvertEventToVideo } from "./nostr/index.js";
import { convertChannelEvent, buildChannelVideoFilters, loadCachedChannelVideos, saveCachedChannelVideos } from "./channelProfileVideos.js";
import { dedupeVideos } from "./utils/videoDeduper.js";
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
import { subscriptions } from "./subscriptions.js";
import { attachHealthBadges } from "./gridHealth.js";
@@ -4427,10 +4428,10 @@ function getCachedChannelVideoEvents(pubkey) {
}
});
if (rawMatches.length) {
return rawMatches;
}
// Include processed videos from allEvents too (raw 30078 first so they win the
// id-dedupe downstream): this surfaces ingested NIP-71 videos for the author so
// their wall renders optimistically on revisit / when the feed already loaded
// them. Cross-ecosystem dedupe collapses any overlap.
const processedMatches = collectMatches(
nostrClient?.allEvents,
(callback) => {
@@ -4447,7 +4448,7 @@ function getCachedChannelVideoEvents(pubkey) {
}
);
return processedMatches;
return [...rawMatches, ...processedMatches];
}
function normalizeRenderableChannelVideo(entry) {
@@ -4461,7 +4462,7 @@ function normalizeRenderableChannelVideo(entry) {
typeof entry.kind === "number";
if (looksLikeRawEvent) {
const converted = sharedConvertEventToVideo(entry);
const converted = convertChannelEvent(entry);
if (converted && !converted.invalid) {
return converted;
}
@@ -4512,7 +4513,7 @@ function buildRenderableChannelVideos({ events = [], app } = {}) {
const newestByRoot =
app?.dedupeVideosByRoot?.(uniqueVideos) ??
dedupeToNewestByRoot(uniqueVideos);
dedupeVideos(uniqueVideos);
let videos = newestByRoot.filter((video) => !video.deleted);
videos = videos.filter((video) => {
@@ -5352,10 +5353,13 @@ async function loadUserVideos(pubkey) {
let renderedFromCache = false;
if (container) {
// In-memory cache (warm) first; else the persisted per-channel cache so a
// COLD hard-refresh paints last-seen videos before relays connect.
const cachedEvents = getCachedChannelVideoEvents(pubkey);
if (cachedEvents.length) {
const cacheSource = cachedEvents.length ? cachedEvents : loadCachedChannelVideos(pubkey);
if (cacheSource.length) {
const cachedVideos = buildRenderableChannelVideos({
events: cachedEvents,
events: cacheSource,
app
});
if (cachedVideos.length) {
@@ -5382,13 +5386,8 @@ async function loadUserVideos(pubkey) {
});
try {
// 1) Build filter for videos from this pubkey
const filter = {
kinds: [30078],
authors: [pubkey],
"#t": ["video"],
limit: 200
};
// 1) Build filters for this pubkey's videos: native kind-30078 + NIP-71.
const filters = buildChannelVideoFilters(pubkey);
// 2) Collect raw events from all relays
const events = [];
@@ -5401,7 +5400,7 @@ async function loadUserVideos(pubkey) {
try {
const fallbackEvents = await nostrClient.pool.list(
Array.from(DEFAULT_RELAY_URLS),
[filter]
filters
);
if (Array.isArray(fallbackEvents)) {
events.push(...fallbackEvents);
@@ -5412,7 +5411,7 @@ async function loadUserVideos(pubkey) {
} else {
const settled = await pMapSettled(
relayList,
(url) => nostrClient.pool.list([url], [filter]),
(url) => nostrClient.pool.list([url], filters),
{ concurrency: RELAY_BACKGROUND_CONCURRENCY }
);
settled.forEach((result, index) => {
@@ -5442,6 +5441,7 @@ async function loadUserVideos(pubkey) {
}
const videos = buildRenderableChannelVideos({ events, app });
saveCachedChannelVideos(pubkey, videos);
const rendered = await renderChannelVideosFromList({
videos,
container,
+85
View File
@@ -0,0 +1,85 @@
// Channel-profile video sourcing: a creator's wall should show BOTH their
// bitvid-native kind-30078 videos AND the NIP-71 videos they published via other
// apps (Nostube etc.) — matching what the main feed shows via ingest. Previously
// the profile fetched only kind-30078, so cross-posted NIP-71 videos were absent.
import { convertEventToVideo as sharedConvertEventToVideo } from "./nostr/index.js";
import { buildVideoFromNip71Event, NIP71_KINDS } from "./nostr/nip71IngestAdapter.js";
// Two filters for one author: native video notes (kind 30078, tagged "video")
// and their NIP-71 videos (kinds 21/22/34235/34236).
export function buildChannelVideoFilters(pubkey, { limit = 200 } = {}) {
return [
{ kinds: [30078], authors: [pubkey], "#t": ["video"], limit },
{ kinds: Array.from(NIP71_KINDS), authors: [pubkey], limit },
];
}
// Convert a raw channel event to a bitvid video object. Foreign NIP-71 kinds go
// through the ingest adapter (which also rejects bitvid's own outbound mirrors so
// they don't duplicate the canonical kind-30078 note); everything else uses the
// standard kind-30078 converter. May return an { invalid: true } object — the
// caller filters those out (same contract as convertEventToVideo).
export function convertChannelEvent(entry) {
const kind = Number(entry?.kind);
if (NIP71_KINDS.has(kind)) {
return buildVideoFromNip71Event(entry);
}
return sharedConvertEventToVideo(entry);
}
// ---- Per-channel persisted cache (instant cold-load paint) -------------------
// Mirrors the main feed's optimistic cache: on a cold hard-refresh of a profile,
// render the last-seen videos from localStorage BEFORE relays connect, then the
// live fetch replaces them. Keyed per author; bounded so storage can't grow
// without limit.
const CHANNEL_CACHE_KEY = "bitvid:channel-videos:v1";
const MAX_CACHED_CHANNELS = 20;
const MAX_VIDEOS_PER_CHANNEL = 60;
function normPubkey(pubkey) {
return typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
}
function readChannelCacheMap() {
try {
if (typeof localStorage === "undefined") return {};
const raw = localStorage.getItem(CHANNEL_CACHE_KEY);
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === "object" ? parsed : {};
} catch (error) {
return {};
}
}
export function loadCachedChannelVideos(pubkey) {
const pk = normPubkey(pubkey);
if (!pk) return [];
const entry = readChannelCacheMap()[pk];
return entry && Array.isArray(entry.videos) ? entry.videos : [];
}
export function saveCachedChannelVideos(pubkey, videos) {
const pk = normPubkey(pubkey);
if (!pk || !Array.isArray(videos) || typeof localStorage === "undefined") {
return;
}
try {
const map = readChannelCacheMap();
// Drop the heavy raw `tags` array — the render + dedupe paths use parsed
// top-level fields; the live fetch restores full objects moments later.
const slim = videos.slice(0, MAX_VIDEOS_PER_CHANNEL).map(({ tags, ...rest }) => rest);
map[pk] = { videos: slim, ts: Date.now() };
const keys = Object.keys(map);
if (keys.length > MAX_CACHED_CHANNELS) {
keys
.sort((a, b) => (map[a]?.ts || 0) - (map[b]?.ts || 0))
.slice(0, keys.length - MAX_CACHED_CHANNELS)
.forEach((k) => delete map[k]);
}
localStorage.setItem(CHANNEL_CACHE_KEY, JSON.stringify(map));
} catch (error) {
// best-effort; cache is non-critical
}
}
+2
View File
@@ -24,6 +24,7 @@ import {
ADMIN_WHITELIST_MODE_STORAGE_KEY,
DEFAULT_WHITELIST_MODE_ENABLED,
ALLOW_NSFW_CONTENT,
FEATURE_NIP71_INGEST,
DEFAULT_TRUST_SEED_NPUBS,
DEFAULT_BLUR_THRESHOLD,
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
@@ -117,6 +118,7 @@ export const ADMIN_LIST_NAMESPACE = "bitvid:admin"; // Reserved for Nostr lists
export const ADMIN_COMMUNITY_BLACKLIST_SOURCES = "community:blacklist:sources";
export const ADMIN_COMMUNITY_BLACKLIST_PREFIX = "community:blacklist";
export { ALLOW_NSFW_CONTENT };
export { FEATURE_NIP71_INGEST };
export { DEFAULT_TRUST_SEED_NPUBS };
export { DEFAULT_BLUR_THRESHOLD };
export { DEFAULT_AUTOPLAY_BLOCK_THRESHOLD };
+23 -1
View File
@@ -6,6 +6,7 @@ import {
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD as CONFIG_DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
DEFAULT_PLAYBACK_SOURCE,
DEFAULT_PLAYBACK_START_TIMEOUT,
FEATURE_NIP71_INGEST as CONFIG_FEATURE_NIP71_INGEST,
IS_DEV_MODE,
} from "./config.js";
@@ -35,7 +36,10 @@ export const DEFAULT_CACHE_TTL_MS = FIVE_MINUTES_MS;
export const PROFILE_CACHE_TTL_MS = TEN_MINUTES_MS;
export const URL_HEALTH_TTL_MS = FORTY_FIVE_MINUTES_MS;
export const URL_HEALTH_RETRY_MS = FIVE_MINUTES_MS;
export const URL_PROBE_TIMEOUT_MS = 8 * ONE_SECOND_MS;
// Per-source playability probe budget. Kept tight so a dead host yields a fast
// verdict (and the card hides quickly) — a slow-but-valid CDN still gets a retry
// via URL_HEALTH/RETRY paths. Lowered from 8s.
export const URL_PROBE_TIMEOUT_MS = 4 * ONE_SECOND_MS;
function coerceNonNegativeInteger(value, fallback) {
const numeric = Number(value);
@@ -182,6 +186,12 @@ const DEFAULT_FLAGS = Object.freeze({
URL_FIRST_ENABLED: DEFAULT_PLAYBACK_SOURCE !== "torrent", // try URL before magnet in the player
FEATURE_WATCH_HISTORY_V2: true,
FEATURE_PUBLISH_NIP71: false,
// Opt-in NIP-71 *mirror* (addressable 34235/36) managed per-video from the
// My Videos tab. Independent of FEATURE_PUBLISH_NIP71 (legacy 21/22 auto-publish).
FEATURE_NIP71_MIRROR: true,
// Inbound ingest of NIP-71 videos published by other Nostr apps. Sourced from
// the admin instance config so a deployer flips it in one place.
FEATURE_NIP71_INGEST: CONFIG_FEATURE_NIP71_INGEST !== false,
FEATURE_HASHTAG_PREFERENCES: false,
FEATURE_SEARCH_FILTERS: Boolean(IS_DEV_MODE),
FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes.
@@ -202,6 +212,8 @@ const runtimeFlags = (() => {
URL_FIRST_ENABLED: DEFAULT_FLAGS.URL_FIRST_ENABLED,
FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2,
FEATURE_PUBLISH_NIP71: DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71,
FEATURE_NIP71_MIRROR: DEFAULT_FLAGS.FEATURE_NIP71_MIRROR,
FEATURE_NIP71_INGEST: DEFAULT_FLAGS.FEATURE_NIP71_INGEST,
FEATURE_HASHTAG_PREFERENCES: DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES,
FEATURE_SEARCH_FILTERS: DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS,
FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS,
@@ -233,6 +245,16 @@ export let FEATURE_PUBLISH_NIP71 = coerceBoolean(
DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71
);
export let FEATURE_NIP71_MIRROR = coerceBoolean(
runtimeFlags.FEATURE_NIP71_MIRROR,
DEFAULT_FLAGS.FEATURE_NIP71_MIRROR
);
export let FEATURE_NIP71_INGEST = coerceBoolean(
runtimeFlags.FEATURE_NIP71_INGEST,
DEFAULT_FLAGS.FEATURE_NIP71_INGEST
);
export let FEATURE_HASHTAG_PREFERENCES = coerceBoolean(
runtimeFlags.FEATURE_HASHTAG_PREFERENCES,
DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES
+52
View File
@@ -176,6 +176,12 @@ function normalizeNip71Kind(value) {
if (numeric === 22) {
return 22;
}
if (numeric === 34235) {
return 34235;
}
if (numeric === 34236) {
return 34236;
}
return 21;
}
@@ -199,6 +205,9 @@ function buildImetaTags(variants) {
const dim = stringFromInput(variant.dim);
const url = stringFromInput(variant.url);
const x = stringFromInput(variant.x);
const ox = stringFromInput(variant.ox);
const magnet = stringFromInput(variant.magnet);
const infohash = stringFromInput(variant.i);
const rawMime = stringFromInput(variant.m);
const mime = rawMime ? rawMime.toLowerCase() : "";
const duration = formatNonNegativeNumber(variant.duration, {
@@ -221,6 +230,9 @@ function buildImetaTags(variants) {
!dim &&
!url &&
!x &&
!ox &&
!magnet &&
!infohash &&
!mime &&
!duration &&
!bitrate &&
@@ -241,9 +253,19 @@ function buildImetaTags(variants) {
if (x) {
entries.push(`x ${x}`);
}
if (ox) {
entries.push(`ox ${ox}`);
}
if (mime) {
entries.push(`m ${mime}`);
}
// WebTorrent rides standard NIP-94 imeta fields so torrent-aware clients can use it.
if (magnet) {
entries.push(`magnet ${magnet}`);
}
if (infohash) {
entries.push(`i ${infohash}`);
}
if (duration) {
entries.push(`duration ${duration}`);
}
@@ -456,6 +478,15 @@ function parseImetaTag(tag) {
case "x":
variant.x = parsed.value;
break;
case "ox":
variant.ox = parsed.value;
break;
case "magnet":
variant.magnet = parsed.value;
break;
case "i":
variant.i = parsed.value;
break;
case "m":
variant.m = parsed.value;
break;
@@ -489,6 +520,9 @@ function parseImetaTag(tag) {
Boolean(variant.dim) ||
Boolean(variant.url) ||
Boolean(variant.x) ||
Boolean(variant.ox) ||
Boolean(variant.magnet) ||
Boolean(variant.i) ||
Boolean(variant.m) ||
(variant.duration !== undefined && variant.duration !== null && variant.duration !== "") ||
(variant.bitrate !== undefined && variant.bitrate !== null && variant.bitrate !== "") ||
@@ -1481,6 +1515,20 @@ export function convertEventToVideo(event = {}) {
const isPrivate = parsedContent.isPrivate === true;
const isNsfw = parsedContent.isNsfw === true;
const isForKids = parsedContent.isForKids === true && !isNsfw;
const hashtags = Array.isArray(parsedContent.hashtags)
? parsedContent.hashtags
.filter((value) => typeof value === "string" && value.trim())
.map((value) => value.trim())
: [];
const toPositiveInt = (value) => {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
};
const width = toPositiveInt(parsedContent.width);
const height = toPositiveInt(parsedContent.height);
const durationValue = Number(parsedContent.duration);
const duration =
Number.isFinite(durationValue) && durationValue > 0 ? durationValue : undefined;
const videoRootId = safeTrim(parsedContent.videoRootId) || event.id;
const wsField = safeTrim(parsedContent.ws);
const xsField = safeTrim(parsedContent.xs);
@@ -1584,6 +1632,10 @@ export function convertEventToVideo(event = {}) {
description,
mode,
deleted,
hashtags,
width,
height,
duration,
ws,
xs,
storagePointer,
+251
View File
@@ -0,0 +1,251 @@
// Adapter: converts a *foreign* NIP-71 video event (published by another Nostr
// app — kinds 21/22 regular, 34235/34236 addressable) into a bitvid video
// object shaped like the output of convertEventToVideo, so it can flow through
// the existing feed pipeline (moderation, trust, NSFW gating, rendering).
//
// This is the inverse of nip71Mirror.js (which builds bitvid → NIP-71). It reads
// the imeta variant + standard NIP-71 tags rather than bitvid's JSON content,
// and maps the `content-warning` tag to bitvid's `isNsfw` flag.
//
// Pure + dependency-free so it is cheat-resistant to test. The ingest *service*
// (nip71IngestService.js) handles subscriptions, dedup, and own-mirror skipping.
import { extractNip71MetadataFromTags } from "./nip71.js";
import { extractBtihFromMagnet, extractMagnetHints } from "../magnetShared.js";
const NIP71_KINDS = new Set([21, 22, 34235, 34236]);
function trimString(value) {
return typeof value === "string" ? value.trim() : "";
}
function parseDim(dim) {
const raw = trimString(dim);
const match = /^(\d+)x(\d+)$/i.exec(raw);
if (!match) {
return { width: undefined, height: undefined };
}
const width = Number(match[1]);
const height = Number(match[2]);
return {
width: Number.isFinite(width) && width > 0 ? width : undefined,
height: Number.isFinite(height) && height > 0 ? height : undefined,
};
}
function dTagFromEvent(event) {
const tags = Array.isArray(event?.tags) ? event.tags : [];
for (const tag of tags) {
if (Array.isArray(tag) && tag[0] === "d") {
const value = trimString(tag[1]);
if (value) {
return value;
}
}
}
return "";
}
// bitvid stamps its own outbound mirrors with ["client","bitvid"]. The ingest
// service uses this to avoid re-listing our own content (which already exists as
// a canonical kind-30078 video).
export function isBitvidMirrorEvent(event) {
const tags = Array.isArray(event?.tags) ? event.tags : [];
return tags.some(
(tag) =>
Array.isArray(tag) &&
tag[0] === "client" &&
trimString(tag[1]).toLowerCase() === "bitvid",
);
}
function pickPrimaryVariant(imeta) {
if (!Array.isArray(imeta) || !imeta.length) {
return null;
}
// Prefer a variant that advertises a video mimetype and a url; fall back to
// the first variant that has any playable source.
const withVideoUrl = imeta.find(
(variant) =>
trimString(variant?.url) &&
trimString(variant?.m).toLowerCase().startsWith("video/"),
);
if (withVideoUrl) {
return withVideoUrl;
}
const withUrl = imeta.find((variant) => trimString(variant?.url));
if (withUrl) {
return withUrl;
}
return imeta.find((variant) => trimString(variant?.magnet)) || null;
}
function isVideoVariant(variant) {
if (!trimString(variant?.url)) {
return false;
}
const m = trimString(variant?.m).toLowerCase();
// Accept explicit video/* and untyped variants; exclude audio/image/etc.
return m === "" || m.startsWith("video/");
}
// All playable video URLs from a NIP-71 event's imeta variants, in order, so the
// player + liveness probe can fail over if one host is down. Audio/image
// variants are excluded. De-duplicated by url.
export function collectVideoSources(imeta) {
const out = [];
const seen = new Set();
for (const variant of Array.isArray(imeta) ? imeta : []) {
if (!isVideoVariant(variant)) {
continue;
}
const url = trimString(variant.url);
if (!url || seen.has(url)) {
continue;
}
seen.add(url);
const durationNum = Number(variant.duration);
out.push({
url,
mimeType: trimString(variant.m),
sha256: trimString(variant.x),
dim: trimString(variant.dim),
duration:
Number.isFinite(durationNum) && durationNum > 0 ? durationNum : undefined,
});
}
return out;
}
/**
* Convert a foreign NIP-71 event into a bitvid video object.
*
* @param {object} event - The raw Nostr event (kind 21/22/34235/34236).
* @returns {object} A video object (invalid:false) or { invalid:true, reason }.
*/
export function buildVideoFromNip71Event(event = {}) {
if (!event || typeof event !== "object") {
return { invalid: true, reason: "missing event" };
}
if (!NIP71_KINDS.has(Number(event.kind))) {
return { id: event.id, invalid: true, reason: "not a NIP-71 video kind" };
}
if (isBitvidMirrorEvent(event)) {
// Our own outbound mirror — the canonical 30078 is already in the feed.
return { id: event.id, invalid: true, reason: "bitvid-mirror" };
}
const parsed = extractNip71MetadataFromTags(event);
const metadata = parsed?.metadata || {};
const variant = pickPrimaryVariant(metadata.imeta);
const url = trimString(variant?.url);
const rawMagnet = trimString(variant?.magnet);
const magnet = rawMagnet.toLowerCase().startsWith("magnet:?") ? rawMagnet : "";
if (!url && !magnet) {
return { id: event.id, invalid: true, reason: "missing playable source" };
}
const title = trimString(metadata.title);
if (!title) {
return { id: event.id, invalid: true, reason: "missing title" };
}
const { width, height } = parseDim(variant?.dim);
const durationCandidate = Number(metadata.duration);
const duration =
Number.isFinite(durationCandidate) && durationCandidate > 0
? durationCandidate
: undefined;
const thumbnail =
Array.isArray(variant?.image) && variant.image.length
? trimString(variant.image[0])
: "";
// content-warning → isNsfw so the standard NSFW gate (ALLOW_NSFW_CONTENT) and
// blur thresholds apply to ingested content too.
const isNsfw = Boolean(trimString(metadata.contentWarning));
const hashtags = Array.isArray(metadata.hashtags)
? metadata.hashtags
.map((value) => trimString(value))
.filter((value) => value)
: [];
let infoHash = "";
const iField = trimString(variant?.i).toLowerCase();
if (/^[0-9a-f]{40}$/.test(iField)) {
infoHash = iField;
} else if (magnet) {
infoHash = extractBtihFromMagnet(magnet) || "";
}
const hints = magnet ? extractMagnetHints(magnet) : { ws: "", xs: "" };
// Addressable kinds (34235/36) are keyed by d-tag; regular kinds (21/22) use
// the event id as the stable root.
const dTag = dTagFromEvent(event);
const videoRootId = dTag || trimString(event.id);
// All playable video URLs (mirrors) for fail-over at probe + play time.
const sources = collectVideoSources(metadata.imeta);
// The feed's resolve-posted-at stage fetches per-video kind-30078 history when
// a timestamp isn't already known. Foreign NIP-71 videos have no such history,
// so without a posted-at the feed would fire a blocking history fetch per
// ingested video (a relay storm that stalls the render). Surface the NIP-71
// published_at (falling back to created_at) so the feed short-circuits.
const createdAt = Number.isFinite(event.created_at) ? Math.floor(event.created_at) : 0;
const publishedAtNum = Number(metadata.publishedAt);
const publishedAt =
Number.isFinite(publishedAtNum) && publishedAtNum > 0
? Math.floor(publishedAtNum)
: createdAt;
return {
id: event.id,
videoRootId,
version: 3,
isPrivate: false,
isNsfw,
isForKids: false,
title,
url,
sources,
magnet,
rawMagnet: magnet ? rawMagnet : "",
infoHash,
fileSha256: trimString(variant?.x),
originalFileSha256: trimString(variant?.ox),
thumbnail,
description: trimString(metadata.summary),
mode: "live",
deleted: false,
hashtags,
width,
height,
duration,
ws: hints.ws || "",
xs: hints.xs || "",
storagePointer: null,
infoJsonUrl: "",
enableComments: true,
pubkey: trimString(event.pubkey),
created_at: createdAt,
// Lets the feed's resolve-posted-at stage short-circuit instead of fetching
// non-existent kind-30078 history for foreign videos.
nip71: { publishedAt },
tags: Array.isArray(event.tags) ? event.tags : [],
// Provenance markers so the rest of the app can distinguish ingested videos
// from native bitvid uploads (e.g. to hide WebTorrent-only affordances).
source: "nip71-ingest",
foreign: true,
nip71Kind: Number(event.kind),
invalid: false,
};
}
export { NIP71_KINDS };
+196
View File
@@ -0,0 +1,196 @@
// Phase 0 of the NIP-71 interop plan (docs/nip71-migration-plan.md).
//
// Maps a bitvid video (the kind-30078 content shape) to an opt-in, addressable
// NIP-71 *mirror* event (kind 34235 normal / 34236 short). The 30078 stays the
// canonical record; this mirror is additive and lets the video show up on other
// Nostr video clients (Nostube, Amethyst, …).
//
// Pure / no UX wiring (gated by FEATURE_PUBLISH_NIP71 at the call sites later).
// Hard rules baked in: private videos are NEVER mirrored, and a publicly-playable
// HTTPS url is required (foreign clients can't play magnet-only). bitvid's
// WebTorrent rides standard NIP-94 imeta fields (magnet, i).
import { buildNip71MetadataTags, buildVideoPointerValue } from "./nip71.js";
import { extractBtihFromMagnet } from "../magnetShared.js";
export const NIP71_NORMAL_VIDEO_KIND = 34235;
export const NIP71_SHORT_VIDEO_KIND = 34236;
const VIDEO_MIME_BY_EXT = {
mp4: "video/mp4",
m4v: "video/mp4",
webm: "video/webm",
ogv: "video/ogg",
ogg: "video/ogg",
mov: "video/quicktime",
mkv: "video/x-matroska",
m3u8: "application/x-mpegURL",
};
function str(value) {
return typeof value === "string" ? value.trim() : "";
}
function isHttpsUrl(value) {
return /^https:\/\//i.test(str(value));
}
// Best-effort MIME from the url extension; bitvid doesn't persist a MIME today.
export function deriveVideoMime(url) {
const value = str(url).toLowerCase();
if (!value) {
return "video/mp4";
}
const path = value.split(/[?#]/)[0];
const ext = path.includes(".") ? path.slice(path.lastIndexOf(".") + 1) : "";
return VIDEO_MIME_BY_EXT[ext] || "video/mp4";
}
// Hashtags can live in several places depending on how the video was authored:
// bitvid's own `hashtags`, the parsed NIP-71 metadata (`nip71.hashtags`), or the
// raw `t` tags on the 30078 (produced by the upload form's NIP-71 hashtag editor).
// Source from whichever is present so a shared video stays categorized elsewhere.
// Drops bitvid's internal "video" topic tag and dedupes (case-insensitive).
function resolveHashtags(video) {
let raw = [];
if (Array.isArray(video.hashtags) && video.hashtags.length) {
raw = video.hashtags;
} else if (Array.isArray(video.nip71?.hashtags) && video.nip71.hashtags.length) {
raw = video.nip71.hashtags;
} else if (Array.isArray(video.tags)) {
raw = video.tags
.filter((t) => Array.isArray(t) && t[0] === "t" && typeof t[1] === "string")
.map((t) => t[1]);
}
const out = [];
const seen = new Set();
for (const value of raw) {
const tag = str(value).toLowerCase();
if (!tag || tag === "video" || seen.has(tag)) {
continue;
}
seen.add(tag);
out.push(tag);
}
return out;
}
function normalizePublishedAt(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return Math.floor(Date.now() / 1000);
}
// Tolerate millisecond timestamps.
return numeric > 10_000_000_000 ? Math.floor(numeric / 1000) : Math.floor(numeric);
}
/**
* Build an addressable NIP-71 mirror event from a bitvid video object.
*
* @param {object} video - bitvid video (30078 content shape) + `pubkey`.
* @param {object} [options]
* @param {boolean} [options.short] - Force 34236 (short) / 34235 (normal). When
* omitted, inferred from dimensions (portrait => short).
* @param {number} [options.createdAt] - Signing time (unix seconds). Default now.
* @param {number|string} [options.publishedAt] - First-publish time for the
* `published_at` tag; kept stable across edits. Defaults to the video's
* published_at/created_at, else now.
* @param {string} [options.watchUrl] - Canonical bitvid watch URL for `origin`.
* @returns {{ok:true, event:object} | {ok:false, reason:string}}
*/
export function buildNip71MirrorEvent(video, options = {}) {
if (!video || typeof video !== "object") {
return { ok: false, reason: "invalid" };
}
const videoRootId = str(video.videoRootId);
const title = str(video.title);
const pubkey = str(video.pubkey).toLowerCase();
if (!videoRootId || !title || !pubkey) {
return { ok: false, reason: "invalid" };
}
// Hard rule 1: private videos are never mirrored (no NIP-71 encryption).
if (video.isPrivate === true) {
return { ok: false, reason: "private" };
}
// Hard rule 2: foreign clients need a hosted, publicly-playable HTTPS url.
const url = str(video.url);
if (!isHttpsUrl(url)) {
return { ok: false, reason: "no-url" };
}
const magnet = str(video.magnet);
const infohash =
str(video.infoHash) || (magnet ? str(extractBtihFromMagnet(magnet)) : "");
const width = Number(video.width);
const height = Number(video.height);
const hasDims =
Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0;
const duration = Number(video.duration);
const variant = {
url,
m: deriveVideoMime(url),
image: str(video.thumbnail) ? [str(video.thumbnail)] : [],
x: str(video.fileSha256) || undefined,
ox: str(video.originalFileSha256) || undefined,
magnet: magnet || undefined,
i: infohash || undefined,
dim: hasDims ? `${width}x${height}` : undefined,
duration: Number.isFinite(duration) && duration > 0 ? duration : undefined,
};
const hashtags = resolveHashtags(video);
const metadata = {
title,
// Short accessibility text — NOT a dump of the full description.
alt: title,
publishedAt: normalizePublishedAt(
options.publishedAt ?? video.published_at ?? video.created_at,
),
imeta: [variant],
hashtags,
contentWarning:
video.isNsfw === true ? str(video.nsfwReason) || "nsfw" : undefined,
};
const tags = buildNip71MetadataTags(metadata);
// Addressable identity + bitvid linkage (ignored by foreign clients).
tags.push(["d", videoRootId]);
const aValue = buildVideoPointerValue(pubkey, videoRootId);
if (aValue) {
tags.push(["a", aValue]); // back-pointer to the canonical kind 30078
}
const origin = ["origin", "bitvid", videoRootId];
const watchUrl = str(options.watchUrl);
if (watchUrl) {
origin.push(watchUrl);
}
tags.push(origin);
tags.push(["client", "bitvid"]);
const short =
options.short === true ||
(options.short !== false && hasDims && height > width);
const createdAt = Number.isFinite(options.createdAt)
? Math.floor(options.createdAt)
: Math.floor(Date.now() / 1000);
const event = {
kind: short ? NIP71_SHORT_VIDEO_KIND : NIP71_NORMAL_VIDEO_KIND,
pubkey,
created_at: createdAt,
tags,
content: str(video.description),
};
return { ok: true, event };
}
export default buildNip71MirrorEvent;
+60 -3
View File
@@ -147,17 +147,74 @@ function normalizeRelayList(relays) {
.filter((relay) => relay);
}
// Filter fields that relays hex-decode (from_hex) and therefore MUST contain
// 64-char lowercase hex. A single malformed/odd-length entry makes strict relays
// (e.g. primal) reject the whole REQ with "uneven size input to from_hex",
// silently dropping that subscription's results.
const HEX64_FILTER_FIELDS = ["ids", "authors", "#e", "#p", "#q"];
const HEX64_PATTERN = /^[0-9a-f]{64}$/;
// Strip invalid hex from the hex-only filter fields so one bad value can't get a
// whole REQ rejected. Returns the sanitized filter, or null if a hex field that
// had entries ends up empty (the query would be meaningless/over-broad without
// them, so the filter is dropped rather than silently widened).
export function sanitizeHexFilterFields(filter) {
if (!filter || typeof filter !== "object") {
return filter;
}
let sanitized = filter;
for (const field of HEX64_FILTER_FIELDS) {
const values = filter[field];
if (!Array.isArray(values) || values.length === 0) {
continue;
}
const kept = [];
for (const value of values) {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (HEX64_PATTERN.test(normalized)) {
kept.push(normalized);
}
}
const changed =
kept.length !== values.length || kept.some((value, i) => value !== values[i]);
if (!changed) {
continue;
}
const droppedCount = values.length - kept.length;
if (droppedCount > 0) {
devLogger.warn(
`[toolkit] dropped ${droppedCount} invalid hex value(s) from filter "${field}"` +
` (kinds: ${Array.isArray(filter.kinds) ? filter.kinds.join(",") : "?"})`,
);
}
if (kept.length === 0) {
return null; // a once-populated hex field is now empty — drop the filter
}
if (sanitized === filter) {
sanitized = { ...filter };
}
sanitized[field] = kept;
}
return sanitized;
}
function normalizeFilterList(filters) {
if (!filters) {
return [];
}
let list;
if (!Array.isArray(filters)) {
if (typeof filters === "object" && filters !== null) {
return [filters];
list = [filters];
} else {
return [];
}
return [];
} else {
list = filters.filter((candidate) => candidate && typeof candidate === "object");
}
return filters.filter((candidate) => candidate && typeof candidate === "object");
return list
.map((filter) => sanitizeHexFilterFields(filter))
.filter((filter) => filter && typeof filter === "object");
}
function safeInvoke(handler, args, contextLabel) {
+95 -6
View File
@@ -5,8 +5,45 @@ import { buildNip71MetadataTags, extractNip71MetadataFromTags } from "./nip71.js
import { buildVideoPostEvent } from "../nostrEventSchemas.js";
import { computeSha256HexFromValue } from "../utils/cryptoUtils.js";
import { inferMimeTypeFromUrl } from "../utils/mime.js";
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
import { devLogger } from "../utils/logger.js";
// Pixel dimensions: positive integer or 0 (= "unknown / omit").
export function normalizeVideoDimension(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
}
// Duration in seconds: positive (float ok) or 0 (= omit).
export function normalizeVideoDuration(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : 0;
}
// Normalize free-form hashtag input (array or comma/space string) to a deduped,
// lowercased list. Drops the reserved "video" topic tag and caps the count.
export function sanitizeVideoHashtags(input) {
const raw = Array.isArray(input)
? input
: typeof input === "string"
? input.split(/[\s,]+/)
: [];
const out = [];
const seen = new Set();
for (const item of raw) {
const tag = normalizeHashtag(item);
if (!tag || tag === "video" || seen.has(tag)) {
continue;
}
seen.add(tag);
out.push(tag);
if (out.length >= 20) {
break;
}
}
return out;
}
export function extractVideoPublishPayload(rawPayload) {
let videoData = rawPayload;
let nip71Metadata = null;
@@ -161,13 +198,39 @@ export async function prepareVideoPublishPayload(videoPayload, pubkey, { timesta
contentObject.xs = finalXs;
}
const hashtags = sanitizeVideoHashtags(videoData.hashtags);
if (hashtags.length) {
contentObject.hashtags = hashtags;
}
// Captured-at-upload dimensions/duration: persist so a video's orientation is
// known durably (drives 34236 short selection + a future "shorts" feed filter).
const width = normalizeVideoDimension(videoData.width);
const height = normalizeVideoDimension(videoData.height);
const duration = normalizeVideoDuration(videoData.duration);
if (width) {
contentObject.width = width;
}
if (height) {
contentObject.height = height;
}
if (duration) {
contentObject.duration = duration;
}
const nip71Tags = buildNip71MetadataTags(
nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null
);
const additionalTags = storagePointer
? [["s", storagePointer], ...nip71Tags]
: nip71Tags;
// Emit hashtags as `t` tags on the 30078 so bitvid's own feed scoring (and
// relay queries) pick them up, in addition to persisting them in content.
const hashtagTags = hashtags.map((tag) => ["t", tag]);
const additionalTags = [
...(storagePointer ? [["s", storagePointer]] : []),
...hashtagTags,
...nip71Tags,
];
const event = buildVideoPostEvent({
pubkey: normalizedPubkey,
@@ -438,6 +501,30 @@ export function prepareVideoEditPayload(params) {
contentObject.xs = finalXs;
}
// Prefer edited hashtags, else preserve the base event's so an edit that omits
// the field never strips them.
const hashtags = sanitizeVideoHashtags(
updatedData?.hashtags !== undefined ? updatedData.hashtags : baseEvent.hashtags,
);
if (hashtags.length) {
contentObject.hashtags = hashtags;
}
const hashtagTags = hashtags.map((tag) => ["t", tag]);
// Preserve captured dimensions/duration across edits (prefer edited values).
const width = normalizeVideoDimension(updatedData?.width ?? baseEvent.width);
const height = normalizeVideoDimension(updatedData?.height ?? baseEvent.height);
const duration = normalizeVideoDuration(updatedData?.duration ?? baseEvent.duration);
if (width) {
contentObject.width = width;
}
if (height) {
contentObject.height = height;
}
if (duration) {
contentObject.duration = duration;
}
let metadataForTags =
nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null;
if (!metadataForTags) {
@@ -453,9 +540,11 @@ export function prepareVideoEditPayload(params) {
const nip71Tags = buildNip71MetadataTags(metadataForTags);
const additionalTags = storagePointer
? [["s", storagePointer], ...nip71Tags]
: nip71Tags;
const additionalTags = [
...(storagePointer ? [["s", storagePointer]] : []),
...hashtagTags,
...nip71Tags,
];
// A replaceable event only wins if its created_at is strictly greater than
// the version it replaces (NIP-01 keeps the highest created_at; ties go to
+17 -4
View File
@@ -232,10 +232,23 @@ export async function fetchPayServiceData(url, { fetcher } = {}) {
const targetUrl = sanitizeUrl(url);
const fetchFn = ensureFetchFunction(fetcher);
const response = await fetchFn(targetUrl, {
method: "GET",
headers: { Accept: "application/json" },
});
let response;
try {
response = await fetchFn(targetUrl, {
method: "GET",
headers: { Accept: "application/json" },
});
} catch (error) {
// Browser fetch rejects (TypeError "Failed to fetch") when the recipient's
// Lightning endpoint is unreachable or — common for LNURL — doesn't send CORS
// headers. Surface a clear, actionable message instead of the raw error.
const friendly = new Error(
"Couldn't reach this recipient's Lightning address. It may be offline or blocking browser requests.",
);
friendly.cause = error;
friendly.code = "lnurl-unreachable";
throw friendly;
}
if (!response.ok) {
throw new Error(`Failed to load LNURL metadata (${response.status}).`);
+408
View File
@@ -0,0 +1,408 @@
// Inbound NIP-71 ingest: subscribes to video events published by *other* Nostr
// apps (kinds 21/22/34235/34236), converts them to bitvid video objects, and
// injects them into the active-video store so they flow through the existing
// feed pipeline (whitelist, blacklist, NSFW gate, trust/mute/blur, rendering).
//
// Design — ingest mirrors the whitelist model: bitvid's whitelist is "authors
// allowed to publish/render here," so ingest pulls those same authors' NIP-71
// content from the wider ecosystem. When whitelist mode is ON, the relay
// subscription is scoped to whitelisted pubkeys (bounded, no firehose); when a
// deployer disables whitelist mode, ingest opens to all authors (capped).
//
// All gating is reused, not re-implemented: injected videos carry
// source:"nip71-ingest" + isNsfw (mapped from content-warning), and the
// render-time filter (nostrService.shouldIncludeVideo -> accessControl.canAccess)
// remains the authority. Gated by FEATURE_NIP71_INGEST so a single admin switch
// disables it entirely.
import { FEATURE_NIP71_INGEST } from "../constants.js";
import {
buildVideoFromNip71Event,
NIP71_KINDS,
} from "../nostr/nip71IngestAdapter.js";
import { devLogger, userLogger } from "../utils/logger.js";
const FLUSH_DEBOUNCE_MS = 200;
// Coalesce ingest-driven feed refreshes so a burst of incoming events can't
// trigger a re-render/re-subscription storm. The injected videos are already in
// the active store; this only controls how often we *signal* the feed.
const REFRESH_THROTTLE_MS = 2500;
const DEFAULT_LIMIT = 200;
const SUBSCRIPTION_KEY = "nip71-ingest";
// The admin whitelist hydrates asynchronously (remote fetch, then a second hex
// rebuild once NostrTools loads) and does not always emit a change we can hook
// (e.g. loaded-from-cache unchanged). So if the author scope isn't ready at
// start, poll a bounded number of times until it is, instead of silently never
// subscribing.
const OPEN_RETRY_DELAY_MS = 2000;
const MAX_OPEN_ATTEMPTS = 30; // ~60s of retries
// Defer the first subscription until the native feed has rendered (signalled by
// the first videos:updated). Injecting foreign authors hydrates their WoT graph
// (kind 30000), which during cold-start competes with the initial feed load; a
// fallback covers the empty-feed case where no videos:updated ever fires.
const FEED_READY_FALLBACK_MS = 12000;
export function createNip71IngestService({
nostrClient,
nostrService,
accessControl,
featureEnabled = FEATURE_NIP71_INGEST,
logger = devLogger,
flushDelayMs = FLUSH_DEBOUNCE_MS,
limit = DEFAULT_LIMIT,
openRetryDelayMs = OPEN_RETRY_DELAY_MS,
maxOpenAttempts = MAX_OPEN_ATTEMPTS,
refreshThrottleMs = REFRESH_THROTTLE_MS,
feedReadyFallbackMs = FEED_READY_FALLBACK_MS,
} = {}) {
let subscription = null;
let buffer = [];
let flushTimer = null;
let retryTimer = null;
let attemptsLeft = maxOpenAttempts;
let offWhitelistChange = null;
let started = false;
let lastRefreshAt = 0;
let pendingRefreshTimer = null;
let deferArmed = false;
let deferTimer = null;
let offFeedReady = null;
function isAvailable() {
return (
featureEnabled === true &&
!!nostrClient &&
typeof nostrClient.getSubscriptionManager === "function"
);
}
// Build the relay filter set. Returns null when there is nothing to ingest
// (whitelist mode on but no whitelisted authors yet).
function resolveFilters() {
const kinds = Array.from(NIP71_KINDS);
const whitelistOn = accessControl?.whitelistMode?.() === true;
if (whitelistOn) {
const authors =
typeof accessControl?.getWhitelistPubkeys === "function"
? accessControl.getWhitelistPubkeys().filter(Boolean)
: [];
if (!authors.length) {
return null;
}
return [{ kinds, authors, limit }];
}
return [{ kinds, limit }];
}
function scheduleFlush() {
if (flushTimer) {
return;
}
flushTimer = setTimeout(() => {
flushTimer = null;
flush();
}, flushDelayMs);
}
// Inject a converted foreign video into the active store. Native bitvid videos
// always win for a given root; among ingested videos, newest wins.
function injectVideo(video) {
if (!video || video.invalid) {
return false;
}
const activeMap = nostrClient?.activeMap;
const allEvents = nostrClient?.allEvents;
if (!(activeMap instanceof Map) || !(allEvents instanceof Map)) {
return false;
}
const key =
typeof nostrClient.getActiveKey === "function"
? nostrClient.getActiveKey(video)
: "";
if (!key) {
return false;
}
const existing = activeMap.get(key);
if (existing) {
const existingForeign = existing.source === "nip71-ingest";
if (!existingForeign) {
return false; // native bitvid video wins
}
if (Number(existing.created_at) >= Number(video.created_at)) {
return false; // older or equal ingest
}
}
activeMap.set(key, video);
allEvents.set(video.id, video);
return true;
}
function flush() {
if (!buffer.length) {
return 0;
}
const events = buffer;
buffer = [];
// Collapse to newest-per-root within the batch before injecting.
const newestByRoot = new Map();
for (const event of events) {
const video = buildVideoFromNip71Event(event);
if (video.invalid || !video.videoRootId) {
continue;
}
const prev = newestByRoot.get(video.videoRootId);
if (!prev || Number(video.created_at) > Number(prev.created_at)) {
newestByRoot.set(video.videoRootId, video);
}
}
const injectedVideos = [];
for (const video of newestByRoot.values()) {
if (injectVideo(video)) {
injectedVideos.push(video);
}
}
const injected = injectedVideos.length;
// Also cache into nostrService.videosMap so ingested videos persist and show
// up in the author-scoped feeds + the optimistic/persisted render paths — not
// just the live activeMap engine run. Keeps NIP-71 present across navigation.
if (injected > 0 && typeof nostrService?.cacheVideos === "function") {
try {
nostrService.cacheVideos(injectedVideos);
} catch (error) {
logger?.warn?.("[nip71Ingest] cacheVideos failed", error);
}
}
// Visible (not dev-gated) so ingest is observable in any build while we
// stabilize the feature.
userLogger.info(
`[nip71Ingest] flush: ${events.length} event(s) in, ${injected} injected`,
);
if (injected > 0) {
requestRefresh();
}
return injected;
}
// Signal the feed to re-render, throttled so a burst of ingested events can't
// drive a re-render/re-subscription storm. The injected videos already live in
// the active store; the feed re-reads getFilteredActiveVideos itself, so this
// is purely a "refresh now" nudge.
function requestRefresh() {
if (pendingRefreshTimer) {
return;
}
const now = Date.now();
const elapsed = now - lastRefreshAt;
if (elapsed >= refreshThrottleMs) {
emitRefresh();
return;
}
pendingRefreshTimer = setTimeout(() => {
pendingRefreshTimer = null;
emitRefresh();
}, refreshThrottleMs - elapsed);
}
function emitRefresh() {
lastRefreshAt = Date.now();
let videos = [];
try {
videos =
typeof nostrService?.getFilteredActiveVideos === "function"
? nostrService.getFilteredActiveVideos()
: [];
} catch (error) {
logger?.warn?.("[nip71Ingest] getFilteredActiveVideos failed", error);
}
// Confirm ingested videos survive the feed filter (whitelist/NSFW/etc.).
const foreignCount = Array.isArray(videos)
? videos.filter((v) => v?.source === "nip71-ingest").length
: 0;
userLogger.info(
`[nip71Ingest] refresh: ${foreignCount} ingested of ${videos.length} in filtered feed`,
);
if (typeof nostrService?.emit === "function") {
nostrService.emit("videos:updated", {
videos,
deleted: [],
reason: "nip71-ingest",
});
}
}
// Defer the first start until the native feed has rendered (first
// videos:updated), with a fallback timer for the empty-feed case. Keeps ingest
// off the cold-start critical path.
function startWhenFeedReady() {
if (started || deferArmed || !isAvailable()) {
return false;
}
deferArmed = true;
const begin = () => {
if (typeof offFeedReady === "function") {
offFeedReady();
offFeedReady = null;
}
if (deferTimer) {
clearTimeout(deferTimer);
deferTimer = null;
}
deferArmed = false;
start();
};
if (typeof nostrService?.on === "function") {
offFeedReady = nostrService.on("videos:updated", begin);
}
deferTimer = setTimeout(begin, feedReadyFallbackMs);
return true;
}
function start() {
if (started || !isAvailable()) {
return false;
}
started = true;
attemptsLeft = maxOpenAttempts;
// Re-subscribe when the whitelist changes so the author scope stays current.
if (typeof accessControl?.onWhitelistChange === "function") {
offWhitelistChange = accessControl.onWhitelistChange(() => {
restart();
});
}
return attemptOpen();
}
// Open the subscription; if the author scope isn't ready yet (whitelist still
// hydrating), retry on a timer rather than giving up silently.
function attemptOpen() {
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
if (subscription) {
return true;
}
const opened = openSubscription();
if (opened) {
return true;
}
if (started && attemptsLeft > 0) {
attemptsLeft -= 1;
retryTimer = setTimeout(attemptOpen, openRetryDelayMs);
}
return false;
}
function openSubscription() {
const filters = resolveFilters();
if (!filters) {
return false; // nothing to ingest yet (e.g. whitelist not hydrated) — retry
}
try {
const manager = nostrClient.getSubscriptionManager();
subscription = manager.subscribe({
key: SUBSCRIPTION_KEY,
filters,
label: "nip71-ingest",
onEvent: (event) => {
if (event && NIP71_KINDS.has(Number(event.kind))) {
buffer.push(event);
scheduleFlush();
}
},
});
const authorCount = Array.isArray(filters[0]?.authors)
? filters[0].authors.length
: "all";
userLogger.info(
`[nip71Ingest] subscribed (authors: ${authorCount}, kinds: ${filters[0]?.kinds?.join(",")})`,
);
return true;
} catch (error) {
logger?.warn?.("[nip71Ingest] subscribe failed", error);
subscription = null;
return false;
}
}
function closeSubscription() {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
if (pendingRefreshTimer) {
clearTimeout(pendingRefreshTimer);
pendingRefreshTimer = null;
}
buffer = [];
if (subscription && typeof subscription.close === "function") {
try {
subscription.close();
} catch (error) {
logger?.warn?.("[nip71Ingest] close failed", error);
}
}
subscription = null;
}
function restart() {
if (!started) {
return false;
}
closeSubscription();
attemptsLeft = maxOpenAttempts;
return attemptOpen();
}
function stop() {
closeSubscription();
if (typeof offFeedReady === "function") {
try {
offFeedReady();
} catch (error) {
// best-effort
}
offFeedReady = null;
}
if (deferTimer) {
clearTimeout(deferTimer);
deferTimer = null;
}
deferArmed = false;
if (typeof offWhitelistChange === "function") {
try {
offWhitelistChange();
} catch (error) {
// best-effort
}
offWhitelistChange = null;
}
started = false;
}
return {
isAvailable,
startWhenFeedReady,
start,
stop,
restart,
// Exposed for tests: deterministic flush + single-video injection.
flush,
injectVideo,
resolveFilters,
};
}
+148
View File
@@ -0,0 +1,148 @@
// Per-video opt-in state for the NIP-71 mirror, persisted locally per
// pubkey+videoRootId. Off by default — a publisher explicitly opts a video in
// (from the My Videos tab). Local-only is fine: it's a convenience for the
// management UI; the authoritative state is whether the mirror event exists on
// relays.
const FLAG_KEY = "bitvid:nip71-mirror:v1";
const AUTO_SHARE_KEY = "bitvid:nip71-autoshare:v1";
function readFlags() {
try {
if (typeof localStorage === "undefined") {
return {};
}
const raw = localStorage.getItem(FLAG_KEY);
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === "object" ? parsed : {};
} catch (error) {
return {};
}
}
function writeFlags(flags) {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(FLAG_KEY, JSON.stringify(flags || {}));
}
} catch (error) {
// Best-effort.
}
}
function norm(value) {
return typeof value === "string" ? value.trim() : "";
}
export function isMirrorEnabled(pubkey, videoRootId) {
const pk = norm(pubkey);
const root = norm(videoRootId);
if (!pk || !root) {
return false;
}
return readFlags()[pk]?.[root] === true;
}
export function setMirrorEnabled(pubkey, videoRootId, enabled) {
const pk = norm(pubkey);
const root = norm(videoRootId);
if (!pk || !root) {
return;
}
const flags = readFlags();
const entry = flags[pk] && typeof flags[pk] === "object" ? flags[pk] : {};
if (enabled === true) {
entry[root] = true;
} else {
delete entry[root];
}
flags[pk] = entry;
writeFlags(flags);
}
// Pure decision for a toggle click: given the current opt-in state and the
// service's eligibility check (canMirror), what should happen?
// { action: "remove" } — currently on → tear down
// { action: "publish" } — eligible + off → publish
// { action: "blocked", reason: "<why>" } — off but ineligible (private / nsfw / no-url)
export function resolveMirrorToggle({ enabled, eligibility } = {}) {
if (enabled === true) {
return { action: "remove" };
}
if (!eligibility || eligibility.ok !== true) {
return { action: "blocked", reason: eligibility?.reason || "ineligible" };
}
return { action: "publish" };
}
// Account-level "auto-share new public videos" preference (per pubkey, off by
// default). When on, newly published eligible public videos are mirrored
// automatically — no per-video opt-in needed.
function readAutoShare() {
try {
if (typeof localStorage === "undefined") {
return {};
}
const raw = localStorage.getItem(AUTO_SHARE_KEY);
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === "object" ? parsed : {};
} catch (error) {
return {};
}
}
export function isAutoShareEnabled(pubkey) {
const pk = norm(pubkey);
return pk ? readAutoShare()[pk] === true : false;
}
export function setAutoShareEnabled(pubkey, enabled) {
const pk = norm(pubkey);
if (!pk) {
return;
}
const map = readAutoShare();
if (enabled === true) {
map[pk] = true;
} else {
delete map[pk];
}
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(AUTO_SHARE_KEY, JSON.stringify(map));
}
} catch (error) {
// best-effort
}
}
// On publish of a NEW video: mirror it only when auto-share is on AND it's
// eligible (public + hosted URL + allowed). Otherwise do nothing.
export function resolvePublishSync({ featureOn, autoShare, eligible } = {}) {
if (featureOn !== true || autoShare !== true) {
return { action: "none" };
}
return eligible === true ? { action: "publish" } : { action: "none" };
}
// Lifecycle decisions for keeping an opted-in mirror in sync (used by the
// nostrService edit/delete hooks). Pure so they're cheat-resistant to test.
//
// On EDIT of a mirrored video: re-publish if still eligible; if it became
// ineligible (e.g. flipped private), pull the mirror down and clear the flag.
export function resolveEditSync({ featureOn, enabled, eligible } = {}) {
if (featureOn !== true || enabled !== true) {
return { action: "none" };
}
return eligible === true ? { action: "publish" } : { action: "unshare" };
}
// On DELETE of a mirrored video: remove the mirror and clear the flag.
export function resolveDeleteSync({ featureOn, enabled } = {}) {
if (featureOn !== true || enabled !== true) {
return { action: "none" };
}
return { action: "unshare" };
}
export const NIP71_MIRROR_FLAG_KEY = FLAG_KEY;
+194
View File
@@ -0,0 +1,194 @@
// Phase 1 of the NIP-71 interop plan (docs/nip71-migration-plan.md): publish the
// opt-in addressable NIP-71 mirror (kind 34235/34236) for a bitvid video to the
// user's write/outbox relays.
//
// Builds on the Phase 0 pure builder (buildNip71MirrorEvent) which already enforces
// the "private never mirrored" and "HTTPS url required" rules. This service adds:
// - the site-wide NSFW gate: when ALLOW_NSFW_CONTENT is false, an instance that
// won't surface NSFW must not publish it outward either (moderation audit item).
// - signing with the active signer + publishing to the write (outbox) relay set.
//
// Dependency-injected so it unit-tests without a live signer/relay pool.
import { buildNip71MirrorEvent } from "../nostr/nip71Mirror.js";
import { nostrClient } from "../nostrClientFacade.js";
import { getActiveSigner } from "../nostr/index.js";
import {
publishEventToRelays as defaultPublishEventToRelays,
summarizePublishResults as defaultSummarize,
} from "../nostrPublish.js";
import { ALLOW_NSFW_CONTENT } from "../config.js";
import { userLogger } from "../utils/logger.js";
function str(value) {
return typeof value === "string" ? value.trim() : "";
}
export function createNip71MirrorService({
buildMirrorEvent = buildNip71MirrorEvent,
getActivePubkey = () => str(nostrClient?.pubkey),
getSigner = () => getActiveSigner(),
// Outbox/write set (mirrors getDeletePublishRelays from the delete work).
getWriteRelays = () =>
typeof nostrClient?.getDeletePublishRelays === "function"
? nostrClient.getDeletePublishRelays()
: [],
getPool = () => nostrClient?.pool || null,
publishEventToRelays = defaultPublishEventToRelays,
summarizePublishResults = defaultSummarize,
signEvent = async (template) => {
const signer = getActiveSigner();
if (!signer || typeof signer.signEvent !== "function") {
const err = new Error("Active signer missing signEvent support.");
err.code = "sign-event-missing";
throw err;
}
return signer.signEvent(template);
},
// Instance policy: only mirror NSFW outward when the site permits NSFW.
allowNsfw = () => ALLOW_NSFW_CONTENT === true,
now = () => Date.now(),
} = {}) {
function isAvailable() {
const pubkey = getActivePubkey();
const signer = getSigner();
return Boolean(pubkey) && Boolean(signer) && typeof signer.signEvent === "function";
}
// Whether a video is even eligible for the mirror (drives UI gating too).
function canMirror(video) {
if (!video || typeof video !== "object") {
return { ok: false, reason: "invalid" };
}
if (video.isPrivate === true) {
return { ok: false, reason: "private" };
}
if (video.isNsfw === true && !allowNsfw()) {
return { ok: false, reason: "nsfw-blocked" };
}
if (!/^https:\/\//i.test(str(video.url))) {
return { ok: false, reason: "no-url" };
}
return { ok: true };
}
async function publish(video, options = {}) {
if (!isAvailable()) {
return { ok: false, error: "unavailable" };
}
// Site NSFW policy gate (defense in depth alongside the UI not offering it).
if (video?.isNsfw === true && !allowNsfw()) {
return { ok: false, reason: "nsfw-blocked" };
}
const pubkey = str(video?.pubkey) || getActivePubkey();
const built = buildMirrorEvent(
{ ...video, pubkey },
{ ...options, createdAt: Math.floor(now() / 1000) },
);
if (!built.ok) {
return { ok: false, reason: built.reason };
}
let signed;
try {
signed = await signEvent(built.event);
} catch (error) {
userLogger.warn("[nip71Mirror] Failed to sign mirror event:", error);
return { ok: false, error: "sign-failed", cause: error };
}
const relays = getWriteRelays() || [];
const pool = getPool();
if (!pool || !relays.length) {
return { ok: false, error: "no-relays" };
}
const results = await publishEventToRelays(pool, relays, signed);
const { accepted, failed } = summarizePublishResults(results);
return {
ok: accepted.length > 0,
accepted: accepted.length,
total: accepted.length + failed.length,
event: signed,
};
}
// Tear down the mirror (on delete, or when the user toggles sync off). Belt-and-
// suspenders per the plan: a NIP-09 delete referencing BOTH addressable kinds
// (robust to a since-changed orientation), AND an empty-replace tombstone so
// clients that cache addressable events but ignore NIP-09 still see it cleared.
async function remove(video, options = {}) {
if (!isAvailable()) {
return { ok: false, error: "unavailable" };
}
const pubkey = str(video?.pubkey) || getActivePubkey();
const root = str(video?.videoRootId);
if (!pubkey || !root) {
return { ok: false, error: "invalid" };
}
const createdAt = Math.floor(now() / 1000);
const reason = str(options.reason) || "Removed NIP-71 mirror";
const deleteEvent = {
kind: 5,
pubkey,
created_at: createdAt,
content: reason,
tags: [
["a", `34235:${pubkey}:${root}`],
["a", `34236:${pubkey}:${root}`],
["k", "34235"],
["k", "34236"],
],
};
const width = Number(video?.width);
const height = Number(video?.height);
const portrait =
Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 && height > width;
const tombstoneKind =
options.short === true || (options.short !== false && portrait) ? 34236 : 34235;
const tombstone = {
kind: tombstoneKind,
pubkey,
created_at: createdAt,
// No imeta → nothing plays; title kept so the entry reads as removed.
tags: [["d", root], ["title", str(video?.title) || "[removed]"], ["client", "bitvid"]],
content: "",
};
const relays = getWriteRelays() || [];
const pool = getPool();
if (!pool || !relays.length) {
return { ok: false, error: "no-relays" };
}
let publishedOk = 0;
let total = 0;
for (const template of [deleteEvent, tombstone]) {
total += 1;
let signed;
try {
signed = await signEvent(template);
} catch (error) {
userLogger.warn("[nip71Mirror] Failed to sign teardown event:", error);
continue;
}
const results = await publishEventToRelays(pool, relays, signed);
const { accepted } = summarizePublishResults(results);
if (accepted.length > 0) {
publishedOk += 1;
}
}
return { ok: publishedOk > 0, published: publishedOk, total };
}
return { isAvailable, canMirror, publish, remove };
}
export const nip71MirrorService = createNip71MirrorService();
export default nip71MirrorService;
+139
View File
@@ -0,0 +1,139 @@
// Keeps an opted-in NIP-71 mirror (kind 34235/36) in lockstep with the canonical
// kind-30078 video on edit/delete — the "no UX fuss" glue so the two events never
// drift. Extracted from nostrService (which is size-capped); called from its
// edit/delete handlers. All best-effort: a mirror failure must never break the
// core edit/delete.
import { FEATURE_NIP71_MIRROR } from "../constants.js";
import { nip71MirrorService } from "./nip71MirrorService.js";
import { convertEventToVideo } from "../nostr/index.js";
import {
isMirrorEnabled,
setMirrorEnabled,
isAutoShareEnabled,
resolveEditSync,
resolveDeleteSync,
resolvePublishSync,
} from "./nip71MirrorFlags.js";
import { devLogger } from "../utils/logger.js";
// On publish of a NEW video, auto-mirror it when the account opted into
// "auto-share new public videos" and it's eligible. Sets the per-video flag so
// later edits/deletes keep it in sync. Best-effort.
export async function syncNip71MirrorAfterPublish(detail = {}) {
const pubkey = typeof detail?.pubkey === "string" ? detail.pubkey : "";
const legacyEvent = detail?.result?.legacy;
if (!pubkey || !legacyEvent || typeof legacyEvent !== "object") {
return;
}
let video;
try {
video = convertEventToVideo(legacyEvent);
} catch (error) {
return;
}
if (!video || video.invalid || !video.videoRootId) {
return;
}
const withPubkey = { ...video, pubkey };
const decision = resolvePublishSync({
featureOn: FEATURE_NIP71_MIRROR,
autoShare: isAutoShareEnabled(pubkey),
eligible: nip71MirrorService.canMirror(withPubkey).ok,
});
if (decision.action !== "publish") {
return;
}
try {
const result = await nip71MirrorService.publish(withPubkey);
if (result?.ok) {
setMirrorEnabled(pubkey, video.videoRootId, true);
}
} catch (error) {
devLogger.warn("[nip71MirrorSync] auto-share publish failed", error);
}
}
export async function syncNip71MirrorAfterEdit({ updatedData, pubkey } = {}) {
if (!updatedData || typeof updatedData !== "object") {
return;
}
const videoRootId =
typeof updatedData.videoRootId === "string" ? updatedData.videoRootId : "";
const authorPubkey = typeof pubkey === "string" ? pubkey : "";
if (!videoRootId || !authorPubkey) {
return;
}
const video = { ...updatedData, pubkey: authorPubkey, videoRootId };
const decision = resolveEditSync({
featureOn: FEATURE_NIP71_MIRROR,
enabled: isMirrorEnabled(authorPubkey, videoRootId),
eligible: nip71MirrorService.canMirror(video).ok,
});
if (decision.action === "none") {
return;
}
try {
if (decision.action === "publish") {
await nip71MirrorService.publish(video);
} else {
// "unshare": no longer eligible (e.g. flipped private) — pull it down.
await nip71MirrorService.remove(video);
setMirrorEnabled(authorPubkey, videoRootId, false);
}
} catch (error) {
devLogger.warn("[nip71MirrorSync] edit sync failed", error);
}
}
// Drive the lifecycle off nostrService's existing "videos:edited"/"videos:deleted"
// events — so the size-capped nostrService needs no changes. Idempotent: a guard
// prevents double-registration. Returns an unsubscribe fn.
let registered = false;
export function initNip71MirrorSync(nostrService) {
if (!nostrService || typeof nostrService.on !== "function" || registered) {
return () => {};
}
registered = true;
const offEdit = nostrService.on("videos:edited", ({ updatedData, pubkey } = {}) => {
void syncNip71MirrorAfterEdit({ updatedData, pubkey });
});
const offDelete = nostrService.on("videos:deleted", ({ videoRootId, video, pubkey } = {}) => {
void syncNip71MirrorAfterDelete({ videoRootId, video, pubkey });
});
const offPublish = nostrService.on("videos:published", (detail) => {
void syncNip71MirrorAfterPublish(detail);
});
return () => {
registered = false;
if (typeof offEdit === "function") offEdit();
if (typeof offDelete === "function") offDelete();
if (typeof offPublish === "function") offPublish();
};
}
export async function syncNip71MirrorAfterDelete({ videoRootId, video, pubkey } = {}) {
const root = typeof videoRootId === "string" ? videoRootId : "";
const authorPubkey = typeof pubkey === "string" ? pubkey : "";
if (!root || !authorPubkey) {
return;
}
const decision = resolveDeleteSync({
featureOn: FEATURE_NIP71_MIRROR,
enabled: isMirrorEnabled(authorPubkey, root),
});
if (decision.action === "none") {
return;
}
try {
await nip71MirrorService.remove({
...(video || {}),
videoRootId: root,
pubkey: authorPubkey,
});
} catch (error) {
devLogger.warn("[nip71MirrorSync] delete sync failed", error);
} finally {
setMirrorEnabled(authorPubkey, root, false);
}
}
+25
View File
@@ -17,6 +17,8 @@ import hashtagPreferences, {
import NwcSettingsService from "../services/nwcSettingsService.js";
import nostrService from "../services/nostrService.js";
import storageService from "../services/storageService.js";
import { initNip71MirrorSync } from "../services/nip71MirrorSync.js";
import { createNip71IngestService } from "../services/nip71IngestService.js";
import watchHistoryService from "../watchHistoryService.js";
import r2Service from "../services/r2Service.js";
import s3UploadService from "../services/s3UploadService.js";
@@ -849,6 +851,29 @@ export default class ApplicationBootstrap {
app.videoSubscription = subscription || null;
}),
);
// Keep an opted-in NIP-71 mirror in lockstep on edit/delete (event-driven so
// nostrService stays untouched).
nostrUnsubscribes.push(initNip71MirrorSync(app.nostrService));
// Inbound NIP-71 ingest: pull video events published by other Nostr apps
// (scoped to whitelisted authors while whitelist mode is on) and surface
// them in the feed. Gated by FEATURE_NIP71_INGEST. Best-effort.
//
// Deferred until the native feed has rendered once: injecting foreign
// authors triggers the moderation/WoT stage to hydrate their social graph
// (kind 30000), which during cold-start competes with the initial feed load
// and can stall it. Starting after the first videos:updated keeps ingest off
// the critical path; a fallback timer covers the empty-feed case.
const nip71IngestService = createNip71IngestService({
nostrClient,
nostrService: app.nostrService,
accessControl,
});
app.nip71IngestService = nip71IngestService;
if (nip71IngestService.isAvailable()) {
nip71IngestService.startWhenFeedReady();
nostrUnsubscribes.push(() => nip71IngestService.stop());
}
nostrUnsubscribes.push(
app.nostrService.on("directMessages:notification", () => {
void app.refreshUnreadDmIndicator({ reason: "dm-notification" });
+32
View File
@@ -1,6 +1,7 @@
// components/UploadModal.js
import { createModalAccessibility } from "./modalAccessibility.js";
import { Nip71FormManager } from "./nip71FormManager.js";
import { probeVideoMetadata } from "../../utils/videoProbe.js";
import { MediaUploader } from "./mediaUploader.js";
import { devLogger, userLogger } from "../../utils/logger.js";
import {
@@ -483,10 +484,35 @@ export class UploadModal {
// --- Immediate Upload Handlers ---
// Best-effort: read the file's dimensions/duration so we can persist them
// (orientation → 34236 short selection + a future "shorts" feed). Never blocks
// upload; failures just leave the metadata unset.
async captureVideoMetadata(file) {
this.capturedMetadata = null;
if (!file || typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
return;
}
const objectUrl = URL.createObjectURL(file);
try {
this.capturedMetadata = await probeVideoMetadata(objectUrl);
} catch (error) {
this.capturedMetadata = null;
} finally {
try {
URL.revokeObjectURL(objectUrl);
} catch (revokeError) {
// ignore
}
}
}
async handleVideoSelection(e) {
const file = e.target.files?.[0];
if (!file) return;
// Probe dimensions/duration in the background while the upload proceeds.
void this.captureVideoMetadata(file);
if (!this.storageConfigured) {
alert("Please configure storage before selecting a file.");
e.target.value = ""; // Clear selection
@@ -900,6 +926,12 @@ export class UploadModal {
enableComments: this.toggles.comments?.checked || true,
...audienceFlags,
// Auto-captured at file-select (best-effort; absent ⇒ omitted). Drives
// 34236 short selection + a future "shorts" feed (height > width).
width: this.capturedMetadata?.width || 0,
height: this.capturedMetadata?.height || 0,
duration: this.capturedMetadata?.duration || 0,
// These might be empty depending on mode, filled below
url: "",
magnet: "",
+113
View File
@@ -4,6 +4,22 @@ import { getApplication } from "../../applicationContext.js";
import { collapseUserVideos } from "./myVideosData.js";
import { classifyVideoHealth, isUrlUnderBase } from "./myVideosHealth.js";
import { reconcileStorage } from "./myVideosReconcile.js";
import { FEATURE_NIP71_MIRROR } from "../../constants.js";
import { nip71MirrorService } from "../../services/nip71MirrorService.js";
import {
isMirrorEnabled,
setMirrorEnabled,
resolveMirrorToggle,
isAutoShareEnabled,
setAutoShareEnabled,
} from "../../services/nip71MirrorFlags.js";
const MIRROR_REASON_TEXT = {
private: "Private videos can't be shared to other apps.",
"nsfw-blocked": "NSFW videos aren't shared from this instance.",
"no-url": "Needs a hosted (HTTPS) URL to appear on other apps.",
invalid: "This video can't be mirrored.",
};
// Maps a health severity to a status-pill style, mirroring
// ProfileRelayController.applyStatusPill so the look stays consistent.
@@ -34,6 +50,8 @@ export class MyVideosController {
this.refreshBtn = null;
this.orphansSection = null;
this.orphanListEl = null;
this.autoShareRow = null;
this.autoShareInput = null;
this.loading = false;
this.publicBaseUrl = "";
this.pubkey = "";
@@ -49,6 +67,10 @@ export class MyVideosController {
document.getElementById("profileMyVideosOrphans") || null;
this.orphanListEl =
document.getElementById("profileMyVideosOrphanList") || null;
this.autoShareRow =
document.getElementById("profileMyVideosAutoShareRow") || null;
this.autoShareInput =
document.getElementById("profileMyVideosAutoShare") || null;
}
registerEventListeners() {
@@ -57,6 +79,26 @@ export class MyVideosController {
void this.populate({ forceFetch: true });
});
}
if (this.autoShareInput instanceof HTMLInputElement) {
this.autoShareInput.addEventListener("change", () => {
const pubkey = this.getPubkey();
if (!pubkey) {
return;
}
setAutoShareEnabled(pubkey, this.autoShareInput.checked === true);
});
}
}
refreshAutoShareToggle(pubkey) {
if (!(this.autoShareRow instanceof HTMLElement)) {
return;
}
const eligible = FEATURE_NIP71_MIRROR && !!pubkey;
this.autoShareRow.classList.toggle("hidden", !eligible);
if (eligible && this.autoShareInput instanceof HTMLInputElement) {
this.autoShareInput.checked = isAutoShareEnabled(pubkey);
}
}
getPubkey() {
@@ -100,6 +142,7 @@ export class MyVideosController {
return;
}
const pubkey = this.getPubkey();
this.refreshAutoShareToggle(pubkey);
if (!pubkey) {
this.renderRows([]);
this.setSummary("");
@@ -369,6 +412,10 @@ export class MyVideosController {
actions.appendChild(
this.buildActionButton("Edit", () => this.handleEdit(video)),
);
const mirrorBtn = this.buildMirrorButton(video);
if (mirrorBtn) {
actions.appendChild(mirrorBtn);
}
actions.appendChild(
this.buildActionButton("Delete", () => this.handleDelete(video), true),
);
@@ -376,6 +423,72 @@ export class MyVideosController {
return actions;
}
// Opt-in "publish to other Nostr video apps (NIP-71)" toggle. Reflects the
// per-video opt-in flag; disabled (with a reason) for ineligible videos.
buildMirrorButton(video) {
if (!FEATURE_NIP71_MIRROR) {
return null;
}
const enabled = isMirrorEnabled(this.pubkey, video?.videoRootId);
const eligibility = nip71MirrorService.canMirror(video);
if (!enabled && eligibility.ok !== true) {
const reason = MIRROR_REASON_TEXT[eligibility.reason] || "Can't be shared.";
const btn = this.buildActionButton("Share off", () => {
this.mainController.showStatus?.(reason);
});
btn.disabled = true;
btn.title = reason;
btn.classList.add("opacity-50");
return btn;
}
return this.buildActionButton(
enabled ? "Shared ✓" : "Share to apps",
(event) => this.handleToggleMirror(video, event?.currentTarget || null),
);
}
async handleToggleMirror(video, btn) {
const enabled = isMirrorEnabled(this.pubkey, video?.videoRootId);
const eligibility = nip71MirrorService.canMirror(video);
const decision = resolveMirrorToggle({ enabled, eligibility });
if (decision.action === "blocked") {
this.mainController.showError?.(
MIRROR_REASON_TEXT[decision.reason] || "This video can't be shared.",
);
return;
}
if (btn) {
btn.disabled = true;
}
try {
if (decision.action === "publish") {
const result = await nip71MirrorService.publish(video);
if (result?.ok) {
setMirrorEnabled(this.pubkey, video.videoRootId, true);
this.mainController.showSuccess?.(
`Shared to other Nostr apps (${result.accepted}/${result.total} relays).`,
);
} else {
this.mainController.showError?.(
"Couldn't share this video. Please try again.",
);
}
} else {
const result = await nip71MirrorService.remove(video);
setMirrorEnabled(this.pubkey, video.videoRootId, false);
this.mainController.showSuccess?.(
result?.ok ? "Removed from other Nostr apps." : "Stopped sharing locally.",
);
}
} catch (error) {
devLogger.warn("[myVideos] mirror toggle failed:", error);
this.mainController.showError?.("Sharing action failed. Please try again.");
} finally {
void this.populate({ forceFetch: true });
}
}
buildActionButton(label, onClick, danger = false) {
const btn = document.createElement("button");
btn.type = "button";
+46 -1
View File
@@ -152,6 +152,21 @@ export default class UrlHealthController {
this.updateUrlHealthBadge(badgeEl, { status: "checking" }, eventId);
// Try the primary url plus any additional NIP-71 source mirrors, so one dead
// host doesn't mark a video offline when an alternate plays.
const sources = Array.isArray(video?.sources) ? video.sources : [];
const candidateUrls = [];
const seenUrls = new Set();
for (const candidate of [
trimmedUrl,
...sources.map((s) => (s && typeof s.url === "string" ? s.url.trim() : "")),
]) {
if (candidate && !seenUrls.has(candidate)) {
seenUrls.add(candidate);
candidateUrls.push(candidate);
}
}
const probeOptions = { confirmPlayable: true };
const getInFlight = this.state.getInFlightUrlProbe;
const existingProbe =
@@ -175,7 +190,7 @@ export default class UrlHealthController {
return;
}
const probePromise = this.probeUrl(trimmedUrl, probeOptions)
const probePromise = this.probeUrlList(candidateUrls, probeOptions)
.then((result) => {
const outcome = result?.outcome || "error";
let entry;
@@ -341,6 +356,36 @@ export default class UrlHealthController {
}
}
// Probe several candidate URLs (mirrors) in order; "ok" as soon as one is
// playable, "timeout" if none played but at least one timed out, else
// "offline". Lets a video with a dead primary host survive on a live mirror.
async probeUrlList(urls, options = {}) {
const list = Array.isArray(urls)
? urls.filter((u) => typeof u === "string" && u.trim())
: [];
if (!list.length) {
return { outcome: "error" };
}
let sawTimeout = false;
for (const url of list) {
let result;
try {
result = await this.probeUrl(url, options);
} catch (err) {
this.logger.warn(`[probeUrlList] probe threw for ${url}:`, err);
continue;
}
const outcome = result?.outcome;
if (outcome === "ok") {
return { outcome: "ok", url };
}
if (outcome === "timeout") {
sawTimeout = true;
}
}
return { outcome: sawTimeout ? "timeout" : "offline" };
}
async probeUrl(url, options = {}) {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
+121
View File
@@ -34,4 +34,125 @@ export function dedupeToNewestByRoot(videos) {
return Array.from(map.values());
}
// Identity keys that mark two events as "the same video" across ecosystems
// (bitvid kind-30078 vs a NIP-71 mirror/original). Scoped per author. Content
// hashes + infohash are the reliable signals; `importedFrom` (Phase 2 on-board
// provenance) links an on-boarded bitvid note to the original foreign event via a
// shared "eid" namespace. Videos with none of these only ever match themselves.
function crossEcosystemIdentityKeys(video) {
const author =
typeof video?.pubkey === "string" ? video.pubkey.trim().toLowerCase() : "";
if (!author) {
return [];
}
const keys = [];
const push = (ns, value) => {
const s = typeof value === "string" ? value.trim().toLowerCase() : "";
if (s) {
keys.push(`${author}|${ns}|${s}`);
}
};
push("sha", video.fileSha256);
push("ox", video.originalFileSha256);
push("ih", video.infoHash);
push("eid", video.id);
push("eid", video.importedFrom);
return keys;
}
function isBitvidNative(video) {
return video?.source !== "nip71-ingest" && video?.foreign !== true;
}
// Within a group of same-video events, prefer the bitvid-native (kind-30078)
// version; among the same kind, the newest (tie: higher id) for determinism.
function pickPreferredVideo(group) {
let best = null;
for (const video of group) {
if (!best) {
best = video;
continue;
}
const nativeV = isBitvidNative(video);
const nativeBest = isBitvidNative(best);
if (nativeV !== nativeBest) {
if (nativeV) best = video;
continue;
}
const tV = Number(video.created_at);
const tBest = Number(best.created_at);
const cV = Number.isFinite(tV) ? tV : -Infinity;
const cBest = Number.isFinite(tBest) ? tBest : -Infinity;
if (cV > cBest || (cV === cBest && String(video.id) > String(best.id))) {
best = video;
}
}
return best;
}
/**
* Collapse duplicates of the same video that appear in BOTH ecosystems (a bitvid
* kind-30078 note and a NIP-71 mirror/original) so a grid never shows both.
* Always prefers the bitvid version. Same-ecosystem, distinct videos are
* untouched. Pure.
*/
export function collapseCrossEcosystem(videos) {
const list = Array.isArray(videos) ? videos.filter(Boolean) : [];
if (list.length < 2) {
return list.slice();
}
// Union-find: merge any videos that share an identity key.
const parent = list.map((_, i) => i);
const find = (i) => {
let root = i;
while (parent[root] !== root) root = parent[root];
while (parent[i] !== root) {
const next = parent[i];
parent[i] = root;
i = next;
}
return root;
};
const union = (a, b) => {
const ra = find(a);
const rb = find(b);
if (ra !== rb) parent[ra] = rb;
};
const keyToIndex = new Map();
list.forEach((video, i) => {
for (const key of crossEcosystemIdentityKeys(video)) {
if (keyToIndex.has(key)) {
union(keyToIndex.get(key), i);
} else {
keyToIndex.set(key, i);
}
}
});
const groups = new Map();
list.forEach((video, i) => {
const root = find(i);
const group = groups.get(root);
if (group) group.push(video);
else groups.set(root, [video]);
});
const result = [];
for (const group of groups.values()) {
result.push(group.length === 1 ? group[0] : pickPreferredVideo(group));
}
return result;
}
/**
* The shared grid dedupe: collapse to newest-per-root, THEN collapse
* cross-ecosystem duplicates (prefer bitvid). Use this everywhere a video list is
* built so every grid behaves identically.
*/
export function dedupeVideos(videos) {
return collapseCrossEcosystem(dedupeToNewestByRoot(videos));
}
export default dedupeToNewestByRoot;
+80
View File
@@ -0,0 +1,80 @@
// Auto-capture a video's dimensions + duration by loading just its metadata into
// a hidden <video> element. Used at upload time so bitvid can persist width/height
// (orientation → 34236 short selection + a future "shorts" feed) and duration.
//
// Element creation is injectable so the resolution logic is unit-testable without
// a DOM or a real media file. Always resolves (never rejects): null on failure.
export function probeVideoMetadata(src, { createVideoEl, timeoutMs = 8000 } = {}) {
return new Promise((resolve) => {
const source = typeof src === "string" ? src.trim() : "";
if (!source) {
resolve(null);
return;
}
let el = null;
try {
el =
typeof createVideoEl === "function"
? createVideoEl()
: typeof document !== "undefined" && document.createElement
? document.createElement("video")
: null;
} catch (error) {
el = null;
}
if (!el || typeof el.addEventListener !== "function") {
resolve(null);
return;
}
let settled = false;
const cleanup = () => {
try {
el.removeAttribute?.("src");
el.load?.();
} catch (error) {
// ignore
}
};
const finish = (result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
cleanup();
resolve(result);
};
const timer = setTimeout(() => finish(null), timeoutMs);
try {
el.preload = "metadata";
el.muted = true;
} catch (error) {
// ignore (fakes may not allow these)
}
el.addEventListener("loadedmetadata", () => {
const width = Number(el.videoWidth);
const height = Number(el.videoHeight);
const durationRaw = Number(el.duration);
finish({
width: Number.isFinite(width) && width > 0 ? Math.floor(width) : 0,
height: Number.isFinite(height) && height > 0 ? Math.floor(height) : 0,
duration: Number.isFinite(durationRaw) && durationRaw > 0 ? durationRaw : 0,
});
});
el.addEventListener("error", () => finish(null));
try {
el.src = source;
} catch (error) {
finish(null);
}
});
}
export default probeVideoMetadata;
+73
View File
@@ -0,0 +1,73 @@
// Batching the community-blacklist curator fetch into a single multi-author REQ
// (instead of one kind-30000 REQ per curator — the cold-start relay storm).
// Tests the pure filter-builder + result-selector.
import assert from "node:assert/strict";
import test from "node:test";
import {
buildBatchedReferenceFilter,
selectNewestEventsForReferences,
} from "../js/adminListBatch.js";
const A = "a".repeat(64);
const B = "b".repeat(64);
function evt({ id, pubkey, d, created_at }) {
return { id, pubkey, created_at, tags: [["d", d]] };
}
test("builds one filter with all unique authors + d-tags", () => {
const filter = buildBatchedReferenceFilter([
{ authorHex: A, dTag: "community:blacklist" },
{ authorHex: B, dTag: "community:blacklist" },
{ authorHex: A, dTag: "community:blacklist" }, // dup
]);
assert.deepEqual(filter.kinds, [30000]);
assert.deepEqual(filter.authors.sort(), [A, B]);
assert.deepEqual(filter["#d"], ["community:blacklist"]);
});
test("returns null when there are no usable references", () => {
assert.equal(buildBatchedReferenceFilter([]), null);
assert.equal(buildBatchedReferenceFilter([{ authorHex: "", dTag: "" }]), null);
});
test("selects the newest event per (author, d-tag) reference", () => {
const references = [
{ authorHex: A, dTag: "list-1" },
{ authorHex: B, dTag: "list-1" },
];
const events = [
evt({ id: "a-old", pubkey: A, d: "list-1", created_at: 100 }),
evt({ id: "a-new", pubkey: A, d: "list-1", created_at: 200 }),
evt({ id: "b-only", pubkey: B, d: "list-1", created_at: 50 }),
];
const matched = selectNewestEventsForReferences(events, references);
assert.deepEqual(matched.map((e) => e.id), ["a-new", "b-only"]);
});
test("discards cross-product events that aren't real references", () => {
// The batched filter (authors A,B × d-tags d1,d2) can return (A,d2) even
// though only (A,d1) and (B,d2) were requested. Those must be dropped.
const references = [
{ authorHex: A, dTag: "d1" },
{ authorHex: B, dTag: "d2" },
];
const events = [
evt({ id: "a-d1", pubkey: A, d: "d1", created_at: 10 }),
evt({ id: "a-d2-crossproduct", pubkey: A, d: "d2", created_at: 99 }),
evt({ id: "b-d2", pubkey: B, d: "d2", created_at: 10 }),
];
const matched = selectNewestEventsForReferences(events, references);
assert.deepEqual(matched.map((e) => e.id).sort(), ["a-d1", "b-d2"]);
});
test("tie-break on created_at uses higher event id (deterministic)", () => {
const references = [{ authorHex: A, dTag: "d1" }];
const events = [
evt({ id: "id-aaa", pubkey: A, d: "d1", created_at: 100 }),
evt({ id: "id-zzz", pubkey: A, d: "d1", created_at: 100 }),
];
const matched = selectNewestEventsForReferences(events, references);
assert.deepEqual(matched.map((e) => e.id), ["id-zzz"]);
});
+81
View File
@@ -0,0 +1,81 @@
// Channel-profile wall must source BOTH native kind-30078 videos and the
// creator's NIP-71 videos (cross-posted via other apps), while skipping bitvid's
// own outbound mirrors so they don't duplicate the canonical note.
import assert from "node:assert/strict";
import test from "node:test";
import {
buildChannelVideoFilters,
convertChannelEvent,
} from "../js/channelProfileVideos.js";
const PK = "a".repeat(64);
test("builds a native (30078) filter AND a NIP-71 filter for the author", () => {
const filters = buildChannelVideoFilters(PK);
assert.equal(filters.length, 2);
const native = filters.find((f) => f.kinds.includes(30078));
assert.deepEqual(native.authors, [PK]);
assert.deepEqual(native["#t"], ["video"]);
const nip71 = filters.find((f) => !f.kinds.includes(30078));
assert.deepEqual(nip71.kinds.slice().sort((a, b) => a - b), [21, 22, 34235, 34236]);
assert.deepEqual(nip71.authors, [PK]);
});
test("converts a foreign NIP-71 event into a renderable video", () => {
const v = convertChannelEvent({
id: "evt",
pubkey: PK,
kind: 34235,
created_at: 1000,
content: "desc",
tags: [
["d", "root"],
["title", "Nostube clip"],
["imeta", "url https://example.com/v.mp4", "m video/mp4"],
],
});
assert.equal(v.invalid, false);
assert.equal(v.title, "Nostube clip");
assert.equal(v.source, "nip71-ingest");
});
test("skips bitvid's own mirror (returns invalid so the caller drops it)", () => {
const v = convertChannelEvent({
id: "mirror",
pubkey: PK,
kind: 34235,
created_at: 1000,
content: "",
tags: [
["d", "root"],
["client", "bitvid"],
["title", "Our mirror"],
["imeta", "url https://example.com/v.mp4", "m video/mp4"],
],
});
assert.equal(v.invalid, true);
assert.equal(v.reason, "bitvid-mirror");
});
test("routes kind-30078 events through the standard converter", () => {
const v = convertChannelEvent({
id: "native",
pubkey: PK,
kind: 30078,
created_at: 2000,
content: JSON.stringify({
version: 3,
title: "Native bitvid video",
videoRootId: "vr",
url: "https://example.com/native.mp4",
}),
tags: [["d", "vr"], ["t", "video"]],
});
assert.equal(v.invalid, false);
assert.equal(v.title, "Native bitvid video");
// Not stamped as ingested — it's a native note.
assert.notEqual(v.source, "nip71-ingest");
});
+64
View File
@@ -0,0 +1,64 @@
// Per-channel persisted cache: instant cold-load paint of a profile wall.
// Round-trips last-seen videos via localStorage, strips heavy tags, and bounds
// both per-channel count and number of cached channels.
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import {
loadCachedChannelVideos,
saveCachedChannelVideos,
} from "../js/channelProfileVideos.js";
const PK = "a".repeat(64);
test("round-trips videos for a channel, stripping raw tags", () => {
localStorage.clear();
saveCachedChannelVideos(PK, [
{ id: "v1", title: "One", pubkey: PK, created_at: 1, url: "https://e/1.mp4", tags: [["e", "x"]] },
]);
const out = loadCachedChannelVideos(PK);
assert.equal(out.length, 1);
assert.equal(out[0].id, "v1");
assert.equal(out[0].url, "https://e/1.mp4");
assert.equal(out[0].tags, undefined, "heavy tags array not persisted");
});
test("scoped per pubkey", () => {
localStorage.clear();
saveCachedChannelVideos(PK, [{ id: "v1", title: "x", pubkey: PK, created_at: 1 }]);
assert.equal(loadCachedChannelVideos("b".repeat(64)).length, 0);
});
test("caps videos per channel (60)", () => {
localStorage.clear();
const many = Array.from({ length: 100 }, (_, i) => ({
id: `v${i}`,
title: `t${i}`,
pubkey: PK,
created_at: i,
}));
saveCachedChannelVideos(PK, many);
assert.equal(loadCachedChannelVideos(PK).length, 60);
});
test("evicts the least-recently-saved channel past the cap (20)", () => {
localStorage.clear();
for (let i = 0; i < 22; i += 1) {
const pk = i.toString(16).padStart(64, "0");
saveCachedChannelVideos(pk, [{ id: `v${i}`, title: "t", pubkey: pk, created_at: i }]);
}
// The two oldest channels (0,1) should have been evicted; the newest remains.
const oldest = "0".padStart(64, "0");
const newest = (21).toString(16).padStart(64, "0");
assert.equal(loadCachedChannelVideos(oldest).length, 0, "oldest evicted");
assert.equal(loadCachedChannelVideos(newest).length, 1, "newest kept");
});
test("missing / empty inputs are safe no-ops", () => {
localStorage.clear();
saveCachedChannelVideos("", [{ id: "v" }]); // no pubkey
saveCachedChannelVideos(PK, null); // bad videos
assert.deepEqual(loadCachedChannelVideos(PK), []);
assert.deepEqual(loadCachedChannelVideos(""), []);
});
+111
View File
@@ -0,0 +1,111 @@
// Cross-ecosystem dedupe: when the same video exists as a bitvid kind-30078 note
// AND a NIP-71 mirror/original, show exactly one — always the bitvid version.
// This is the shared pass behind every grid (feed, channel, search, ...).
import assert from "node:assert/strict";
import test from "node:test";
import {
collapseCrossEcosystem,
dedupeVideos,
} from "../js/utils/videoDeduper.js";
const PK = "a".repeat(64);
const SHA = "f".repeat(64);
const bitvid = (over = {}) => ({
id: "bitvid-id",
pubkey: PK,
videoRootId: "bitvid-root",
created_at: 100,
// native: no nip71-ingest source
...over,
});
const foreign = (over = {}) => ({
id: "foreign-id",
pubkey: PK,
videoRootId: "foreign-root",
created_at: 200,
source: "nip71-ingest",
foreign: true,
...over,
});
test("same file hash → keeps the bitvid version, drops the foreign one", () => {
const out = collapseCrossEcosystem([
foreign({ fileSha256: SHA }),
bitvid({ fileSha256: SHA }),
]);
assert.equal(out.length, 1);
assert.equal(out[0].id, "bitvid-id");
});
test("prefers bitvid even when the foreign version is NEWER", () => {
const out = collapseCrossEcosystem([
bitvid({ fileSha256: SHA, created_at: 1 }),
foreign({ fileSha256: SHA, created_at: 9999 }),
]);
assert.equal(out.length, 1);
assert.equal(out[0].id, "bitvid-id");
});
test("matches on infohash too", () => {
const ih = "1".repeat(40);
const out = collapseCrossEcosystem([
foreign({ infoHash: ih }),
bitvid({ infoHash: ih }),
]);
assert.deepEqual(out.map((v) => v.id), ["bitvid-id"]);
});
test("matches via on-board provenance link (importedFrom -> foreign event id)", () => {
const out = collapseCrossEcosystem([
foreign({ id: "orig-evt" }),
bitvid({ id: "imported-note", importedFrom: "orig-evt" }),
]);
assert.equal(out.length, 1);
assert.equal(out[0].id, "imported-note");
});
test("distinct videos (no shared identity) are NOT merged", () => {
const out = collapseCrossEcosystem([
bitvid({ id: "v1", fileSha256: "1".repeat(64) }),
foreign({ id: "v2", fileSha256: "2".repeat(64) }),
]);
assert.equal(out.length, 2);
});
test("hash-less videos are not merged (documented limitation)", () => {
const out = collapseCrossEcosystem([
bitvid({ id: "v1" }),
foreign({ id: "v2" }),
]);
assert.equal(out.length, 2);
});
test("two foreign versions of one file collapse to the newest (no bitvid present)", () => {
const out = collapseCrossEcosystem([
foreign({ id: "old", fileSha256: SHA, created_at: 100 }),
foreign({ id: "new", fileSha256: SHA, created_at: 200 }),
]);
assert.equal(out.length, 1);
assert.equal(out[0].id, "new");
});
test("dedupeVideos collapses by root THEN across ecosystems", () => {
// Two bitvid events same root (older + newer) + a foreign mirror by hash.
const out = dedupeVideos([
bitvid({ id: "old", videoRootId: "r", created_at: 1, fileSha256: SHA }),
bitvid({ id: "new", videoRootId: "r", created_at: 2, fileSha256: SHA }),
foreign({ id: "mirror", fileSha256: SHA, created_at: 3 }),
]);
assert.equal(out.length, 1);
assert.equal(out[0].id, "new", "newest bitvid per root wins, foreign mirror dropped");
});
test("different authors with the same hash are NOT merged (identity is per-author)", () => {
const out = collapseCrossEcosystem([
bitvid({ id: "mine", pubkey: PK, fileSha256: SHA }),
foreign({ id: "theirs", pubkey: "b".repeat(64), fileSha256: SHA }),
]);
assert.equal(out.length, 2);
});
+64
View File
@@ -0,0 +1,64 @@
// Sanitizing hex-only filter fields (ids/authors/#e/#p/#q) before they reach a
// relay. One odd-length/non-hex value otherwise makes strict relays reject the
// whole REQ ("uneven size input to from_hex"), silently dropping results.
import assert from "node:assert/strict";
import test from "node:test";
import { sanitizeHexFilterFields } from "../js/nostr/toolkit.js";
const HEX_A = "a".repeat(64);
const HEX_B = "b".repeat(64);
const ODD = "abc"; // odd length -> from_hex failure
const SHORT = "a".repeat(63); // wrong length
test("strips invalid hex from authors but keeps valid entries", () => {
const out = sanitizeHexFilterFields({ kinds: [0], authors: [HEX_A, ODD, HEX_B] });
assert.deepEqual(out.authors, [HEX_A, HEX_B]);
assert.deepEqual(out.kinds, [0], "non-hex fields untouched");
});
test("lowercases and trims valid hex", () => {
const out = sanitizeHexFilterFields({ ids: [` ${HEX_A.toUpperCase()} `] });
assert.deepEqual(out.ids, [HEX_A]);
});
test("drops the whole filter when a populated hex field becomes empty", () => {
// If every author was invalid, removing the key would broaden the query to
// "all authors" — drop the filter instead.
const out = sanitizeHexFilterFields({ kinds: [1], authors: [ODD, SHORT] });
assert.equal(out, null);
});
test("sanitizes event/pubkey tag filters (#e/#p/#q)", () => {
const out = sanitizeHexFilterFields({
kinds: [1984],
"#e": [HEX_A, ODD],
"#p": [HEX_B],
});
assert.deepEqual(out["#e"], [HEX_A]);
assert.deepEqual(out["#p"], [HEX_B]);
});
test("leaves non-hex tag fields (#a/#d/#t) alone", () => {
const filter = {
kinds: [21, 22],
"#a": ["30078:" + HEX_A + ":some-d-tag"],
"#d": ["subscriptions"],
"#t": ["music"],
};
const out = sanitizeHexFilterFields(filter);
assert.deepEqual(out["#a"], filter["#a"]);
assert.deepEqual(out["#d"], filter["#d"]);
assert.deepEqual(out["#t"], filter["#t"]);
});
test("returns the same object when nothing needs sanitizing (no needless copy)", () => {
const filter = { kinds: [0], authors: [HEX_A] };
const out = sanitizeHexFilterFields(filter);
assert.equal(out, filter);
});
test("ignores empty/absent hex fields", () => {
const out = sanitizeHexFilterFields({ kinds: [1], authors: [] });
assert.deepEqual(out, { kinds: [1], authors: [] });
});
+47
View File
@@ -0,0 +1,47 @@
// A recipient's Lightning endpoint being unreachable (down, or — common for
// LNURL — missing CORS headers) makes the browser fetch reject with
// "Failed to fetch". The zap flow should surface a clear, actionable message,
// not the raw error.
import assert from "node:assert/strict";
import test from "node:test";
import { fetchPayServiceData } from "../js/payments/lnurl.js";
test("network/CORS failure throws a friendly, coded error", async () => {
const fetcher = async () => {
throw new TypeError("Failed to fetch");
};
await assert.rejects(
() => fetchPayServiceData("https://example.com/.well-known/lnurlp/x", { fetcher }),
(err) => {
assert.match(err.message, /reach this recipient's Lightning address/i);
assert.equal(err.code, "lnurl-unreachable");
assert.ok(err.cause, "preserves the original error as cause");
return true;
},
);
});
test("a non-OK HTTP response still reports a status error (unchanged)", async () => {
const fetcher = async () => ({ ok: false, status: 502 });
await assert.rejects(
() => fetchPayServiceData("https://example.com/.well-known/lnurlp/x", { fetcher }),
/Failed to load LNURL metadata \(502\)/,
);
});
test("a valid LNURL pay response parses (no false friendly error)", async () => {
const fetcher = async () => ({
ok: true,
json: async () => ({
callback: "https://example.com/cb",
minSendable: 1000,
maxSendable: 100000,
tag: "payRequest",
}),
});
const meta = await fetchPayServiceData("https://example.com/.well-known/lnurlp/x", {
fetcher,
});
assert.equal(meta.callback, "https://example.com/cb");
});
+227
View File
@@ -0,0 +1,227 @@
// Foreign NIP-71 video event -> bitvid video object adapter. Scenario-style:
// asserts the externally observable shape a feed item needs, and the safety
// behaviors (own-mirror skip, content-warning -> isNsfw, source provenance).
import assert from "node:assert/strict";
import test from "node:test";
import {
buildVideoFromNip71Event,
isBitvidMirrorEvent,
collectVideoSources,
} from "../js/nostr/nip71IngestAdapter.js";
const SHA = "a".repeat(64);
function foreignEvent(overrides = {}) {
return {
id: "evt-1",
pubkey: "pk-foreign",
kind: 21,
created_at: 1_700_000_000,
content: "A description from another client",
tags: [
["title", "Foreign Video"],
[
"imeta",
"url https://example.com/v.mp4",
"m video/mp4",
"dim 1920x1080",
"image https://example.com/thumb.jpg",
`x ${SHA}`,
],
["t", "music"],
["duration", "240"],
],
...overrides,
};
}
test("maps a foreign NIP-71 event to a playable bitvid video", () => {
const v = buildVideoFromNip71Event(foreignEvent());
assert.equal(v.invalid, false);
assert.equal(v.title, "Foreign Video");
assert.equal(v.url, "https://example.com/v.mp4");
assert.equal(v.thumbnail, "https://example.com/thumb.jpg");
assert.equal(v.description, "A description from another client");
assert.equal(v.width, 1920);
assert.equal(v.height, 1080);
assert.equal(v.duration, 240);
assert.deepEqual(v.hashtags, ["music"]);
assert.equal(v.fileSha256, SHA);
assert.equal(v.pubkey, "pk-foreign");
// Provenance markers so the app can distinguish ingested content.
assert.equal(v.source, "nip71-ingest");
assert.equal(v.foreign, true);
assert.equal(v.nip71Kind, 21);
// Never treat foreign content as private (no encryption in NIP-71).
assert.equal(v.isPrivate, false);
});
test("surfaces nip71.publishedAt so the feed skips per-video history fetches", () => {
// The published_at tag becomes nip71.publishedAt (feed resolve-posted-at uses
// it to avoid a blocking kind-30078 history fetch per ingested video).
const withPublished = buildVideoFromNip71Event(
foreignEvent({
tags: [
["title", "Has published_at"],
["published_at", "1700000123"],
["imeta", "url https://example.com/v.mp4", "m video/mp4"],
],
}),
);
assert.equal(withPublished.nip71.publishedAt, 1700000123);
// Falls back to created_at when there's no published_at tag.
const noPublished = buildVideoFromNip71Event(foreignEvent({ created_at: 1699999999 }));
assert.equal(noPublished.nip71.publishedAt, 1699999999);
});
test("content-warning maps to isNsfw so the NSFW gate applies", () => {
const clean = buildVideoFromNip71Event(foreignEvent());
assert.equal(clean.isNsfw, false);
const flagged = buildVideoFromNip71Event(
foreignEvent({
tags: [
["title", "Spicy"],
["imeta", "url https://example.com/v.mp4", "m video/mp4"],
["content-warning", "explicit"],
],
}),
);
assert.equal(flagged.isNsfw, true);
});
test("addressable kinds key the root on the d-tag; regular kinds on the event id", () => {
const regular = buildVideoFromNip71Event(foreignEvent({ id: "regular-id", kind: 22 }));
assert.equal(regular.videoRootId, "regular-id");
const addressable = buildVideoFromNip71Event(
foreignEvent({
id: "addr-id",
kind: 34235,
tags: [
["d", "stable-root"],
["title", "Addr"],
["imeta", "url https://example.com/v.mp4", "m video/mp4"],
],
}),
);
assert.equal(addressable.videoRootId, "stable-root");
});
test("skips bitvid's own outbound mirrors (avoids duplicate listings)", () => {
const mirror = foreignEvent({
kind: 34235,
tags: [
["d", "root"],
["client", "bitvid"],
["title", "Our mirror"],
["imeta", "url https://example.com/v.mp4", "m video/mp4"],
],
});
assert.equal(isBitvidMirrorEvent(mirror), true);
const v = buildVideoFromNip71Event(mirror);
assert.equal(v.invalid, true);
assert.equal(v.reason, "bitvid-mirror");
});
test("rejects events with no playable source or no title", () => {
const noSource = buildVideoFromNip71Event(
foreignEvent({ tags: [["title", "No media"]] }),
);
assert.equal(noSource.invalid, true);
assert.equal(noSource.reason, "missing playable source");
const noTitle = buildVideoFromNip71Event(
foreignEvent({ tags: [["imeta", "url https://example.com/v.mp4", "m video/mp4"]] }),
);
assert.equal(noTitle.invalid, true);
assert.equal(noTitle.reason, "missing title");
});
test("rejects non-NIP-71 kinds", () => {
const v = buildVideoFromNip71Event(foreignEvent({ kind: 30078 }));
assert.equal(v.invalid, true);
assert.equal(v.reason, "not a NIP-71 video kind");
});
test("collectVideoSources keeps video urls in order, excludes audio/image, dedupes", () => {
const sources = collectVideoSources([
{ url: "https://a/v.mp4", m: "video/mp4", x: "a".repeat(64), dim: "1920x1080", duration: "120" },
{ url: "https://a/v.mp3", m: "audio/mpeg" },
{ url: "https://b/v.webm", m: "video/webm" },
{ url: "https://a/v.mp4", m: "video/mp4" }, // dup
{ url: "https://c/untyped" }, // untyped accepted
{ url: "https://a/thumb.jpg", m: "image/jpeg" },
]);
assert.deepEqual(
sources.map((s) => s.url),
["https://a/v.mp4", "https://b/v.webm", "https://c/untyped"],
);
assert.equal(sources[0].sha256, "a".repeat(64));
assert.equal(sources[0].dim, "1920x1080");
assert.equal(sources[0].duration, 120);
});
test("real Walker/podcast event (mp4 + mp3 imeta): one video source, mp3 excluded", () => {
const event = {
id: "a425040161016e64245282037c179bcb4aaaf1ce98024c9d7c59b32aabe1c3d2",
pubkey: "b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc",
kind: 21,
created_at: 1781815633,
content: "Privacy isn't about having something to hide…",
tags: [
["title", "The Praxeology of Privacy"],
["published_at", "1781815633"],
[
"imeta",
"url https://relay.towardsliberty.com/84751f62.mp4",
"x 84751f62034f7396a75c0ba5096964cee2a4d65a076f655b43b6324b0fbefecd",
"m video/mp4",
"duration 5556",
"image https://relay.towardsliberty.com/a2e8159.jpg",
],
[
"imeta",
"url https://relay.towardsliberty.com/2885921.mp3",
"m audio/mpeg",
"duration 5556",
],
["t", "bitcoin"],
],
};
const v = buildVideoFromNip71Event(event);
assert.equal(v.invalid, false);
assert.equal(v.url, "https://relay.towardsliberty.com/84751f62.mp4");
assert.equal(v.sources.length, 1, "only the video imeta becomes a source");
assert.equal(v.sources[0].url, "https://relay.towardsliberty.com/84751f62.mp4");
});
test("multiple video mirrors all become sources (fail-over)", () => {
const v = buildVideoFromNip71Event({
id: "e", pubkey: "p", kind: 22, created_at: 1,
content: "",
tags: [
["title", "Mirrored"],
["imeta", "url https://m1/v.mp4", "m video/mp4"],
["imeta", "url https://m2/v.mp4", "m video/mp4"],
],
});
assert.deepEqual(v.sources.map((s) => s.url), ["https://m1/v.mp4", "https://m2/v.mp4"]);
});
test("extracts a webtorrent magnet + infohash when present", () => {
const magnet = `magnet:?xt=urn:btih:${"b".repeat(40)}&dn=clip`;
const v = buildVideoFromNip71Event(
foreignEvent({
tags: [
["title", "Torrent clip"],
["imeta", `magnet ${magnet}`, "m video/mp4", `i ${"c".repeat(40)}`],
],
}),
);
assert.equal(v.invalid, false);
assert.equal(v.magnet, magnet);
assert.equal(v.infoHash, "c".repeat(40), "prefers the imeta `i` infohash field");
});
+305
View File
@@ -0,0 +1,305 @@
// Inbound NIP-71 ingest service. Scenario-style with a virtualized nostrClient
// (in-memory activeMap/allEvents + a fake subscription manager) and a fake
// accessControl. Asserts observable outcomes: foreign videos land in the active
// store and trigger a refresh; native videos are never clobbered; the relay
// subscription is scoped to the whitelist; and FEATURE_NIP71_INGEST off makes
// the service inert.
import assert from "node:assert/strict";
import test from "node:test";
import { createNip71IngestService } from "../js/services/nip71IngestService.js";
const KINDS = [21, 22, 34235, 34236];
function activeKey(video) {
return video.videoRootId ? `ROOT:${video.videoRootId}` : `LEGACY:${video.id}`;
}
function makeHarness({ whitelistOn = false, whitelist = [] } = {}) {
const activeMap = new Map();
const allEvents = new Map();
let capturedFilters = null;
let onEvent = null;
let closed = false;
let whitelistListener = null;
const manager = {
subscribe(opts) {
capturedFilters = opts.filters;
onEvent = opts.onEvent;
return {
close() {
closed = true;
},
update() {},
};
},
};
const emitted = [];
const nostrClient = {
activeMap,
allEvents,
getActiveKey: activeKey,
getSubscriptionManager: () => manager,
};
const feedListeners = new Set();
const cached = [];
const nostrService = {
emit(name, detail) {
emitted.push({ name, detail });
},
on(name, fn) {
if (name === "videos:updated") {
feedListeners.add(fn);
}
return () => feedListeners.delete(fn);
},
getFilteredActiveVideos: () => Array.from(activeMap.values()),
cacheVideos: (videos) => cached.push(...videos),
};
const accessControl = {
whitelistMode: () => whitelistOn,
getWhitelistPubkeys: () => whitelist,
onWhitelistChange: (fn) => {
whitelistListener = fn;
return () => {
whitelistListener = null;
};
},
};
return {
activeMap,
allEvents,
emitted,
cached,
get capturedFilters() {
return capturedFilters;
},
get closed() {
return closed;
},
fireEvent: (e) => onEvent?.(e),
triggerWhitelistChange: () => whitelistListener?.(),
triggerFeedReady: () => feedListeners.forEach((fn) => fn({})),
nostrClient,
nostrService,
accessControl,
};
}
function foreignEvent(overrides = {}) {
return {
id: overrides.id || "evt-1",
pubkey: overrides.pubkey || "author-1",
kind: overrides.kind || 21,
created_at: overrides.created_at ?? 1000,
content: "desc",
tags: overrides.tags || [
["title", overrides.title || "Foreign"],
["imeta", `url ${overrides.url || "https://e/v.mp4"}`, "m video/mp4"],
],
};
}
function makeService(harness, extra = {}) {
return createNip71IngestService({
nostrClient: harness.nostrClient,
nostrService: harness.nostrService,
accessControl: harness.accessControl,
featureEnabled: true,
flushDelayMs: 0,
// Disable the hydration retry by default so each test is deterministic and
// leaves no dangling timers; the retry behavior is covered explicitly below.
maxOpenAttempts: 0,
// Emit refreshes synchronously (no throttle timer) for deterministic tests.
refreshThrottleMs: 0,
...extra,
});
}
test("ingests a foreign NIP-71 video into the active store and signals a refresh", () => {
const h = makeHarness();
const svc = makeService(h);
assert.equal(svc.start(), true);
h.fireEvent(foreignEvent({ id: "e1", title: "Hello" }));
const injected = svc.flush();
assert.equal(injected, 1);
const stored = h.activeMap.get("ROOT:e1");
assert.ok(stored, "video present in activeMap");
assert.equal(stored.title, "Hello");
assert.equal(stored.source, "nip71-ingest");
assert.ok(h.allEvents.has("e1"), "raw video cached in allEvents");
const refresh = h.emitted.find((e) => e.name === "videos:updated");
assert.ok(refresh, "emits videos:updated");
assert.equal(refresh.detail.reason, "nip71-ingest");
});
test("caches injected videos into nostrService (persisted/author-feed paths)", () => {
const h = makeHarness();
const svc = makeService(h);
svc.start();
h.fireEvent(foreignEvent({ id: "e1", title: "Hello" }));
svc.flush();
assert.equal(h.cached.length, 1, "injected video forwarded to cacheVideos");
assert.equal(h.cached[0].id, "e1");
});
test("never clobbers a native bitvid video sharing the same root", () => {
const h = makeHarness();
const native = { id: "native", videoRootId: "shared", source: "nostr", created_at: 500, title: "Native" };
h.activeMap.set("ROOT:shared", native);
const svc = makeService(h);
svc.start();
h.fireEvent(foreignEvent({ id: "foreign", created_at: 9999, tags: [["d", "shared"], ["title", "Foreign"], ["imeta", "url https://e/v.mp4", "m video/mp4"], ], kind: 34235 }));
const injected = svc.flush();
assert.equal(injected, 0, "native video is not overwritten even by a newer foreign event");
assert.equal(h.activeMap.get("ROOT:shared"), native);
});
test("among ingested videos for one root, newest wins", () => {
const h = makeHarness();
const svc = makeService(h);
svc.start();
h.fireEvent(foreignEvent({ id: "old", kind: 34235, created_at: 100, tags: [["d", "r"], ["title", "Old"], ["imeta", "url https://e/v1.mp4", "m video/mp4"]] }));
svc.flush();
h.fireEvent(foreignEvent({ id: "new", kind: 34235, created_at: 200, tags: [["d", "r"], ["title", "New"], ["imeta", "url https://e/v2.mp4", "m video/mp4"]] }));
svc.flush();
assert.equal(h.activeMap.get("ROOT:r").title, "New");
// An older event for the same root must not resurrect.
h.fireEvent(foreignEvent({ id: "older", kind: 34235, created_at: 50, tags: [["d", "r"], ["title", "Older"], ["imeta", "url https://e/v3.mp4", "m video/mp4"]] }));
svc.flush();
assert.equal(h.activeMap.get("ROOT:r").title, "New", "older event does not override newer");
});
test("scopes the relay subscription to whitelisted authors when whitelist mode is on", () => {
const h = makeHarness({ whitelistOn: true, whitelist: ["aa", "bb"] });
const svc = makeService(h);
assert.equal(svc.start(), true);
const filters = h.capturedFilters;
assert.ok(Array.isArray(filters) && filters.length === 1);
assert.deepEqual(filters[0].kinds.slice().sort((a, b) => a - b), KINDS);
assert.deepEqual(filters[0].authors, ["aa", "bb"], "subscribes only to whitelisted authors");
});
test("with whitelist on but no authors, does not open a subscription (nothing to ingest)", () => {
const h = makeHarness({ whitelistOn: true, whitelist: [] });
const svc = makeService(h);
assert.equal(svc.start(), false, "no subscription opened");
assert.equal(h.capturedFilters, null);
});
test("opens an unscoped (capped) subscription when whitelist mode is off", () => {
const h = makeHarness({ whitelistOn: false });
const svc = makeService(h);
svc.start();
const filters = h.capturedFilters;
assert.ok(filters[0].authors === undefined, "no author scope when whitelist disabled");
assert.ok(Number.isFinite(filters[0].limit), "still capped by a limit");
});
test("retries until the whitelist hydrates, then subscribes (no missed-emit race)", async () => {
// Simulates the real startup race: whitelist mode is on but the hex author
// set isn't populated yet at start(), and no change event is emitted.
const h = makeHarness({ whitelistOn: true, whitelist: [] });
const svc = makeService(h, { maxOpenAttempts: 5, openRetryDelayMs: 5 });
assert.equal(svc.start(), false, "cannot open yet — authors not hydrated");
assert.equal(h.capturedFilters, null);
// Authors hydrate a moment later, WITHOUT firing onWhitelistChange.
h.accessControl.getWhitelistPubkeys = () => ["aa", "bb"];
// Wait past a retry tick.
await new Promise((resolve) => setTimeout(resolve, 20));
assert.ok(h.capturedFilters, "retry opened the subscription after hydration");
assert.deepEqual(h.capturedFilters[0].authors, ["aa", "bb"]);
svc.stop();
});
test("re-subscribes when the whitelist changes", () => {
const h = makeHarness({ whitelistOn: true, whitelist: ["aa"] });
const svc = makeService(h);
svc.start();
assert.deepEqual(h.capturedFilters[0].authors, ["aa"]);
// Whitelist grows; the access-control change fires.
h.accessControl.getWhitelistPubkeys = () => ["aa", "cc"];
h.triggerWhitelistChange();
assert.deepEqual(h.capturedFilters[0].authors, ["aa", "cc"], "subscription re-scoped after change");
});
test("startWhenFeedReady defers subscription until the native feed renders", () => {
const h = makeHarness({ whitelistOn: true, whitelist: ["aa"] });
const svc = makeService(h, { feedReadyFallbackMs: 100000 });
assert.equal(svc.startWhenFeedReady(), true);
assert.equal(h.capturedFilters, null, "does not subscribe during cold-start");
// Native feed renders -> first videos:updated -> ingest starts now.
h.triggerFeedReady();
assert.ok(h.capturedFilters, "subscribes once the feed is ready");
assert.deepEqual(h.capturedFilters[0].authors, ["aa"]);
svc.stop();
});
test("startWhenFeedReady falls back to starting if the feed never emits", async () => {
const h = makeHarness({ whitelistOn: true, whitelist: ["aa"] });
const svc = makeService(h, { feedReadyFallbackMs: 5 });
svc.startWhenFeedReady();
assert.equal(h.capturedFilters, null);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.ok(h.capturedFilters, "fallback timer started ingest");
svc.stop();
});
test("coalesces refresh signals so an event burst can't storm the feed", () => {
const h = makeHarness();
const svc = makeService(h, { refreshThrottleMs: 10000 });
svc.start();
h.fireEvent(foreignEvent({ id: "a", kind: 34235, tags: [["d", "ra"], ["title", "A"], ["imeta", "url https://e/a.mp4", "m video/mp4"]] }));
svc.flush();
h.fireEvent(foreignEvent({ id: "b", kind: 34235, tags: [["d", "rb"], ["title", "B"], ["imeta", "url https://e/b.mp4", "m video/mp4"]] }));
svc.flush();
const refreshes = h.emitted.filter((e) => e.name === "videos:updated");
assert.equal(refreshes.length, 1, "only one immediate refresh within the throttle window");
// Both videos still landed in the store regardless of the throttled signal.
assert.ok(h.activeMap.has("ROOT:ra") && h.activeMap.has("ROOT:rb"));
svc.stop();
});
test("FEATURE_NIP71_INGEST off makes the service completely inert", () => {
const h = makeHarness();
const svc = createNip71IngestService({
nostrClient: h.nostrClient,
nostrService: h.nostrService,
accessControl: h.accessControl,
featureEnabled: false,
flushDelayMs: 0,
});
assert.equal(svc.isAvailable(), false);
assert.equal(svc.start(), false);
assert.equal(h.capturedFilters, null, "no relay subscription when ingest is disabled");
});
test("stop() closes the subscription and unhooks the whitelist listener", () => {
const h = makeHarness({ whitelistOn: true, whitelist: ["aa"] });
const svc = makeService(h);
svc.start();
svc.stop();
assert.equal(h.closed, true, "subscription closed");
// After stop, a whitelist change must not reopen anything.
h.triggerWhitelistChange();
});
+94
View File
@@ -0,0 +1,94 @@
// Per-video NIP-71 mirror opt-in: persistence (per pubkey+videoRootId) and the
// pure toggle-decision used by the My Videos tab.
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import {
isMirrorEnabled,
setMirrorEnabled,
resolveMirrorToggle,
resolveEditSync,
resolveDeleteSync,
isAutoShareEnabled,
setAutoShareEnabled,
resolvePublishSync,
} from "../js/services/nip71MirrorFlags.js";
const PK = "a".repeat(64);
test("opt-in flag persists per pubkey+videoRootId and is off by default", () => {
localStorage.clear();
assert.equal(isMirrorEnabled(PK, "root-1"), false, "off by default");
setMirrorEnabled(PK, "root-1", true);
assert.equal(isMirrorEnabled(PK, "root-1"), true);
assert.equal(isMirrorEnabled(PK, "root-2"), false, "scoped per videoRootId");
assert.equal(isMirrorEnabled("b".repeat(64), "root-1"), false, "scoped per pubkey");
setMirrorEnabled(PK, "root-1", false);
assert.equal(isMirrorEnabled(PK, "root-1"), false, "can be turned back off");
});
test("resolveMirrorToggle: enabled => remove", () => {
assert.deepEqual(
resolveMirrorToggle({ enabled: true, eligibility: { ok: true } }),
{ action: "remove" },
);
// remove even if currently ineligible (so a now-private video can be pulled).
assert.deepEqual(
resolveMirrorToggle({ enabled: true, eligibility: { ok: false, reason: "private" } }),
{ action: "remove" },
);
});
test("resolveMirrorToggle: off + eligible => publish", () => {
assert.deepEqual(
resolveMirrorToggle({ enabled: false, eligibility: { ok: true } }),
{ action: "publish" },
);
});
test("resolveMirrorToggle: off + ineligible => blocked with reason", () => {
assert.deepEqual(
resolveMirrorToggle({ enabled: false, eligibility: { ok: false, reason: "no-url" } }),
{ action: "blocked", reason: "no-url" },
);
assert.deepEqual(
resolveMirrorToggle({ enabled: false }),
{ action: "blocked", reason: "ineligible" },
);
});
test("resolveEditSync: keeps a mirrored video in lockstep, unshares when ineligible", () => {
// not mirrored, or feature off → do nothing
assert.deepEqual(resolveEditSync({ featureOn: true, enabled: false, eligible: true }), { action: "none" });
assert.deepEqual(resolveEditSync({ featureOn: false, enabled: true, eligible: true }), { action: "none" });
// mirrored + still eligible → re-publish (no drift)
assert.deepEqual(resolveEditSync({ featureOn: true, enabled: true, eligible: true }), { action: "publish" });
// mirrored but became ineligible (e.g. now private) → pull it down
assert.deepEqual(resolveEditSync({ featureOn: true, enabled: true, eligible: false }), { action: "unshare" });
});
test("resolveDeleteSync: removes the mirror only when it was shared", () => {
assert.deepEqual(resolveDeleteSync({ featureOn: true, enabled: true }), { action: "unshare" });
assert.deepEqual(resolveDeleteSync({ featureOn: true, enabled: false }), { action: "none" });
assert.deepEqual(resolveDeleteSync({ featureOn: false, enabled: true }), { action: "none" });
});
test("auto-share preference persists per pubkey, off by default", () => {
localStorage.clear();
assert.equal(isAutoShareEnabled(PK), false);
setAutoShareEnabled(PK, true);
assert.equal(isAutoShareEnabled(PK), true);
assert.equal(isAutoShareEnabled("c".repeat(64)), false, "scoped per pubkey");
setAutoShareEnabled(PK, false);
assert.equal(isAutoShareEnabled(PK), false);
});
test("resolvePublishSync: auto-mirror a new video only when auto-share on AND eligible", () => {
assert.deepEqual(resolvePublishSync({ featureOn: true, autoShare: true, eligible: true }), { action: "publish" });
assert.deepEqual(resolvePublishSync({ featureOn: true, autoShare: true, eligible: false }), { action: "none" });
assert.deepEqual(resolvePublishSync({ featureOn: true, autoShare: false, eligible: true }), { action: "none" });
assert.deepEqual(resolvePublishSync({ featureOn: false, autoShare: true, eligible: true }), { action: "none" });
});
+147
View File
@@ -0,0 +1,147 @@
// Phase 1: the NIP-71 mirror publish service. Scenarios assert the observable
// outcomes at the publish boundary, especially the site-wide NSFW moderation gate
// (ALLOW_NSFW_CONTENT) — an instance that won't surface NSFW must not publish it
// outward — plus the private / url / availability gates and a real publish.
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { createNip71MirrorService } from "../js/services/nip71MirrorService.js";
import {
publishEventToRelays,
summarizePublishResults,
} from "../js/nostrPublish.js";
const PUBKEY = "a".repeat(64);
function baseVideo(overrides = {}) {
return {
videoRootId: "root-1",
pubkey: PUBKEY,
title: "Hello",
description: "desc",
url: "https://cdn.example.com/v.mp4",
thumbnail: "https://cdn.example.com/t.jpg",
isPrivate: false,
isNsfw: false,
...overrides,
};
}
// Faithful relay twin: stores published events; thenable publish => success path.
function makeRelay() {
const events = [];
return {
events,
publish(_urls, event) {
events.push(event);
return Promise.resolve();
},
};
}
function makeService({ allowNsfw = false, available = true } = {}) {
const relay = makeRelay();
const signer = available
? { signEvent: async (tpl) => ({ ...tpl, id: "id", sig: "sig" }) }
: null;
const service = createNip71MirrorService({
getActivePubkey: () => (available ? PUBKEY : ""),
getSigner: () => signer,
getWriteRelays: () => ["wss://write.relay"],
getPool: () => relay,
publishEventToRelays,
summarizePublishResults,
signEvent: signer ? signer.signEvent : async () => { throw new Error("no signer"); },
allowNsfw: () => allowNsfw === true,
});
return { service, relay };
}
test("publishes a public video as an addressable 34235 mirror to the write relays", async () => {
const { service, relay } = makeService();
const result = await service.publish(baseVideo());
assert.equal(result.ok, true);
assert.equal(result.accepted, 1);
assert.equal(relay.events.length, 1);
assert.equal(relay.events[0].kind, 34235);
assert.equal(relay.events[0].tags.find((t) => t[0] === "d")[1], "root-1");
});
test("NSFW video is NOT mirrored when the instance forbids NSFW (ALLOW_NSFW_CONTENT=false)", async () => {
const { service, relay } = makeService({ allowNsfw: false });
const result = await service.publish(baseVideo({ isNsfw: true }));
assert.equal(result.ok, false);
assert.equal(result.reason, "nsfw-blocked");
assert.equal(relay.events.length, 0, "must not publish NSFW outward");
assert.deepEqual(service.canMirror(baseVideo({ isNsfw: true })), {
ok: false,
reason: "nsfw-blocked",
});
});
test("NSFW video IS mirrored when the instance allows NSFW", async () => {
const { service, relay } = makeService({ allowNsfw: true });
const result = await service.publish(baseVideo({ isNsfw: true, nsfwReason: "mature" }));
assert.equal(result.ok, true);
assert.equal(relay.events.length, 1);
assert.equal(
relay.events[0].tags.find((t) => t[0] === "content-warning")[1],
"mature",
);
});
test("private and url-less videos are refused (builder rules)", async () => {
const { service, relay } = makeService();
assert.equal((await service.publish(baseVideo({ isPrivate: true }))).reason, "private");
assert.equal((await service.publish(baseVideo({ url: "" }))).reason, "no-url");
assert.equal(relay.events.length, 0);
});
test("publish is unavailable without a signer / pubkey", async () => {
const { service } = makeService({ available: false });
assert.equal(service.isAvailable(), false);
const result = await service.publish(baseVideo());
assert.equal(result.ok, false);
assert.equal(result.error, "unavailable");
});
test("remove() publishes a NIP-09 delete (both addressable kinds) AND a tombstone", async () => {
const { service, relay } = makeService();
const result = await service.remove(baseVideo());
assert.equal(result.ok, true);
assert.equal(relay.events.length, 2, "delete + empty-replace tombstone");
const del = relay.events.find((e) => e.kind === 5);
assert.ok(del, "must publish a NIP-09 kind-5 delete");
const aTags = del.tags.filter((t) => t[0] === "a").map((t) => t[1]).sort();
assert.deepEqual(aTags, [
`34235:${PUBKEY}:root-1`,
`34236:${PUBKEY}:root-1`,
], "delete references both addressable kinds (orientation-robust)");
const kTags = del.tags.filter((t) => t[0] === "k").map((t) => t[1]).sort();
assert.deepEqual(kTags, ["34235", "34236"]);
const tomb = relay.events.find((e) => e.kind === 34235 || e.kind === 34236);
assert.ok(tomb, "must publish an empty-replace tombstone");
assert.equal(tomb.tags.find((t) => t[0] === "d")[1], "root-1", "same d-tag");
assert.equal(
tomb.tags.some((t) => t[0] === "imeta"),
false,
"tombstone has no playable imeta",
);
});
test("remove() tombstone uses the short kind for portrait videos", async () => {
const { service, relay } = makeService();
await service.remove(baseVideo({ width: 1080, height: 1920 }));
const tomb = relay.events.find((e) => e.kind === 34235 || e.kind === 34236);
assert.equal(tomb.kind, 34236, "portrait => 34236 tombstone");
});
test("remove() is unavailable without a signer", async () => {
const { service } = makeService({ available: false });
const result = await service.remove(baseVideo());
assert.equal(result.ok, false);
assert.equal(result.error, "unavailable");
});
+46
View File
@@ -0,0 +1,46 @@
// Auto-sync glue: keeps an opted-in NIP-71 mirror in lockstep on edit/delete,
// driven by nostrService's existing videos:edited/videos:deleted events (so the
// size-capped nostrService is untouched).
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import {
syncNip71MirrorAfterDelete,
initNip71MirrorSync,
} from "../js/services/nip71MirrorSync.js";
import { isMirrorEnabled, setMirrorEnabled } from "../js/services/nip71MirrorFlags.js";
const PK = "a".repeat(64);
test("deleting a SHARED video clears its opt-in flag (mirror torn down)", async () => {
localStorage.clear();
setMirrorEnabled(PK, "root-x", true);
await syncNip71MirrorAfterDelete({ videoRootId: "root-x", video: {}, pubkey: PK });
assert.equal(isMirrorEnabled(PK, "root-x"), false, "flag cleared after delete");
});
test("deleting a NON-shared video is a no-op (and never throws)", async () => {
localStorage.clear();
await syncNip71MirrorAfterDelete({ videoRootId: "root-y", video: {}, pubkey: PK });
assert.equal(isMirrorEnabled(PK, "root-y"), false);
});
test("initNip71MirrorSync subscribes to edit+delete events, idempotently", () => {
const events = {};
const svc = {
on(name, handler) {
(events[name] = events[name] || []).push(handler);
return () => {};
},
};
const off = initNip71MirrorSync(svc);
assert.equal(events["videos:edited"]?.length, 1, "subscribes to videos:edited");
assert.equal(events["videos:deleted"]?.length, 1, "subscribes to videos:deleted");
assert.equal(events["videos:published"]?.length, 1, "subscribes to videos:published");
initNip71MirrorSync(svc); // guard prevents double-registration
assert.equal(events["videos:edited"].length, 1, "no double-register");
off(); // resets the guard for other tests
});
+164
View File
@@ -0,0 +1,164 @@
// Phase 0 of the NIP-71 interop plan: bitvid video (kind 30078 shape) -> addressable
// NIP-71 mirror event (kind 34235/34236). Scenarios assert the observable event
// shape AND that bitvid's own NIP-71 parser reads it back (round-trip), plus the
// two hard rules (private never mirrored, HTTPS url required) and short selection.
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import {
buildNip71MirrorEvent,
deriveVideoMime,
NIP71_NORMAL_VIDEO_KIND,
NIP71_SHORT_VIDEO_KIND,
} from "../js/nostr/nip71Mirror.js";
import { extractNip71MetadataFromTags } from "../js/nostr/nip71.js";
const PUBKEY = "a".repeat(64);
const INFOHASH = "b".repeat(40);
function baseVideo(overrides = {}) {
return {
videoRootId: "root-123",
pubkey: PUBKEY,
title: "Crystalline Bliss",
description: "A lo-fi voxel puzzle journey.",
url: "https://cdn.example.com/u/abc/video.mp4",
thumbnail: "https://cdn.example.com/u/abc/thumb.jpg",
magnet: `magnet:?xt=urn:btih:${INFOHASH}&ws=https://cdn.example.com/u/abc/video.mp4`,
infoHash: INFOHASH,
fileSha256: "c".repeat(64),
originalFileSha256: "d".repeat(64),
isPrivate: false,
isNsfw: false,
hashtags: ["gamestr", "voxel"],
duration: 123,
...overrides,
};
}
// Find the imeta tag and parse "key value" entries into an object (last wins,
// images/magnet collected).
function imetaFields(event) {
const tag = event.tags.find((t) => t[0] === "imeta");
assert.ok(tag, "event must have an imeta tag");
const fields = {};
for (let i = 1; i < tag.length; i += 1) {
const sp = tag[i].indexOf(" ");
const k = tag[i].slice(0, sp);
const v = tag[i].slice(sp + 1);
fields[k] = v;
}
return fields;
}
function tagValue(event, name) {
const tag = event.tags.find((t) => t[0] === name);
return tag ? tag[1] : undefined;
}
test("maps a public bitvid video to a spec-shaped addressable 34235 event", async () => {
const res = buildNip71MirrorEvent(baseVideo(), { createdAt: 1000, publishedAt: 900 });
assert.equal(res.ok, true);
const ev = res.event;
assert.equal(ev.kind, NIP71_NORMAL_VIDEO_KIND, "landscape/default => 34235");
assert.equal(ev.pubkey, PUBKEY);
assert.equal(ev.content, "A lo-fi voxel puzzle journey.");
// Required + addressable identity
assert.equal(tagValue(ev, "d"), "root-123", "addressable d-tag = videoRootId");
assert.equal(tagValue(ev, "title"), "Crystalline Bliss");
assert.equal(tagValue(ev, "published_at"), "900", "stable first-publish time");
// Back-pointer + attribution + hashtags
assert.equal(tagValue(ev, "a"), `30078:${PUBKEY}:root-123`, "links to canonical 30078");
const origin = ev.tags.find((t) => t[0] === "origin");
assert.deepEqual(origin.slice(0, 3), ["origin", "bitvid", "root-123"]);
const tTags = ev.tags.filter((t) => t[0] === "t").map((t) => t[1]).sort();
assert.deepEqual(tTags, ["gamestr", "voxel"]);
// imeta: HTTPS url + standard NIP-94 magnet/infohash/hashes
const f = imetaFields(ev);
assert.equal(f.url, "https://cdn.example.com/u/abc/video.mp4");
assert.equal(f.m, "video/mp4");
assert.equal(f.image, "https://cdn.example.com/u/abc/thumb.jpg");
assert.equal(f.x, "c".repeat(64));
assert.equal(f.ox, "d".repeat(64));
assert.equal(f.i, INFOHASH, "infohash rides standard NIP-94 'i'");
assert.ok(f.magnet.startsWith("magnet:?xt=urn:btih:"), "magnet rides standard NIP-94 'magnet'");
assert.equal(f.duration, "123");
});
test("round-trips: the produced event parses back via bitvid's NIP-71 parser", async () => {
const res = buildNip71MirrorEvent(baseVideo());
const parsed = extractNip71MetadataFromTags(res.event);
assert.ok(parsed, "parser must read the mirror event");
const meta = parsed.metadata;
assert.equal(meta.kind, NIP71_NORMAL_VIDEO_KIND, "addressable kind preserved");
assert.equal(parsed.source.kind, NIP71_NORMAL_VIDEO_KIND, "source kind preserved");
assert.equal(meta.title, "Crystalline Bliss");
assert.deepEqual(meta.hashtags.sort(), ["gamestr", "voxel"]);
const v = meta.imeta[0];
assert.equal(v.url, "https://cdn.example.com/u/abc/video.mp4");
assert.equal(v.magnet, baseVideo().magnet, "magnet survives the round-trip");
assert.equal(v.i, INFOHASH, "infohash survives the round-trip");
assert.equal(v.ox, "d".repeat(64), "original-file hash survives the round-trip");
});
test("private videos are NEVER mirrored", async () => {
const res = buildNip71MirrorEvent(baseVideo({ isPrivate: true }));
assert.equal(res.ok, false);
assert.equal(res.reason, "private");
});
test("a video without an HTTPS url cannot be mirrored", async () => {
// magnet-only: bitvid plays it, foreign clients can't.
const res = buildNip71MirrorEvent(baseVideo({ url: "" }));
assert.equal(res.ok, false);
assert.equal(res.reason, "no-url");
const httpRes = buildNip71MirrorEvent(baseVideo({ url: "http://insecure.example/v.mp4" }));
assert.equal(httpRes.ok, false, "non-HTTPS url is refused");
});
test("portrait dimensions select the short kind 34236; content-warning for NSFW", async () => {
const res = buildNip71MirrorEvent(
baseVideo({ width: 1080, height: 1920, isNsfw: true, nsfwReason: "mature" }),
);
assert.equal(res.event.kind, NIP71_SHORT_VIDEO_KIND, "portrait => 34236 short");
assert.equal(tagValue(res.event, "content-warning"), "mature");
assert.equal(imetaFields(res.event).dim, "1080x1920");
});
test("explicit short flag overrides dimension inference", async () => {
const landscapeButShort = buildNip71MirrorEvent(
baseVideo({ width: 1920, height: 1080 }),
{ short: true },
);
assert.equal(landscapeButShort.event.kind, NIP71_SHORT_VIDEO_KIND);
});
test("mirror sources hashtags from nip71.hashtags when video.hashtags is absent", async () => {
const v = baseVideo({ hashtags: undefined, nip71: { hashtags: ["alpha", "beta"] } });
const res = buildNip71MirrorEvent(v);
const tTags = res.event.tags.filter((t) => t[0] === "t").map((t) => t[1]).sort();
assert.deepEqual(tTags, ["alpha", "beta"]);
});
test("mirror sources hashtags from raw `t` tags, dropping the internal 'video' topic", async () => {
const v = baseVideo({
hashtags: undefined,
tags: [["t", "video"], ["t", "Gamestr"], ["t", "gamestr"], ["d", "root-123"]],
});
const res = buildNip71MirrorEvent(v);
const tTags = res.event.tags.filter((t) => t[0] === "t").map((t) => t[1]);
assert.ok(!tTags.includes("video"), "must not leak the bitvid 'video' topic tag");
assert.deepEqual(tTags, ["gamestr"], "deduped + lowercased");
});
test("deriveVideoMime maps common extensions and defaults to mp4", async () => {
assert.equal(deriveVideoMime("https://x/y.webm"), "video/webm");
assert.equal(deriveVideoMime("https://x/y.m3u8?token=1"), "application/x-mpegURL");
assert.equal(deriveVideoMime("https://x/y"), "video/mp4");
});
+57
View File
@@ -0,0 +1,57 @@
// Multi-source URL liveness: probe candidate mirrors in order — healthy as soon
// as one plays, offline only if all fail (so a dead primary host doesn't hide a
// video that has a working mirror).
import assert from "node:assert/strict";
import test from "node:test";
import UrlHealthController from "../js/ui/urlHealthController.js";
function controllerWith(outcomesByUrl) {
const ctrl = new UrlHealthController({ logger: { warn() {} } });
const probed = [];
ctrl.probeUrl = async (url) => {
probed.push(url);
return { outcome: outcomesByUrl[url] || "error" };
};
return { ctrl, probed };
}
test("returns ok on the first playable source and stops probing further", async () => {
const { ctrl, probed } = controllerWith({
"https://dead/v.mp4": "error",
"https://live/v.mp4": "ok",
"https://other/v.mp4": "ok",
});
const res = await ctrl.probeUrlList([
"https://dead/v.mp4",
"https://live/v.mp4",
"https://other/v.mp4",
]);
assert.equal(res.outcome, "ok");
assert.equal(res.url, "https://live/v.mp4");
assert.deepEqual(probed, ["https://dead/v.mp4", "https://live/v.mp4"], "stops at first ok");
});
test("offline only when every source fails", async () => {
const { ctrl } = controllerWith({
"https://a/v.mp4": "error",
"https://b/v.mp4": "error",
});
const res = await ctrl.probeUrlList(["https://a/v.mp4", "https://b/v.mp4"]);
assert.equal(res.outcome, "offline");
});
test("reports timeout if none played but at least one timed out", async () => {
const { ctrl } = controllerWith({
"https://a/v.mp4": "error",
"https://b/v.mp4": "timeout",
});
const res = await ctrl.probeUrlList(["https://a/v.mp4", "https://b/v.mp4"]);
assert.equal(res.outcome, "timeout");
});
test("empty / invalid list returns error", async () => {
const { ctrl } = controllerWith({});
assert.equal((await ctrl.probeUrlList([])).outcome, "error");
assert.equal((await ctrl.probeUrlList(["", " "])).outcome, "error");
});
+94
View File
@@ -0,0 +1,94 @@
// Dimension capture: width/height/duration are persisted in the 30078 content,
// survive edits, parse back onto the video, and drive the NIP-71 mirror's
// normal(34235)-vs-short(34236) selection + imeta dim. (Foundation for a future
// "shorts" vertical feed, which filters on height > width.)
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import {
prepareVideoPublishPayload,
prepareVideoEditPayload,
normalizeVideoDimension,
} from "../js/nostr/videoPayloadBuilder.js";
import { convertEventToVideo } from "../js/nostr/nip71.js";
import {
buildNip71MirrorEvent,
NIP71_NORMAL_VIDEO_KIND,
NIP71_SHORT_VIDEO_KIND,
} from "../js/nostr/nip71Mirror.js";
const PUBKEY = "a".repeat(64);
function publish(extra) {
return prepareVideoPublishPayload(
{ title: "Hi", url: "https://cdn.example.com/v.mp4", ...extra },
PUBKEY,
{ timestamp: 1000 },
);
}
test("normalizeVideoDimension: positive int or 0", () => {
assert.equal(normalizeVideoDimension(1920), 1920);
assert.equal(normalizeVideoDimension("1080"), 1080);
assert.equal(normalizeVideoDimension(0), 0);
assert.equal(normalizeVideoDimension(-5), 0);
assert.equal(normalizeVideoDimension("abc"), 0);
});
test("publish persists width/height/duration in content; parser exposes them", async () => {
const { event } = await publish({
videoRootId: "root-1",
width: 1080,
height: 1920,
duration: 42.5,
});
const content = JSON.parse(event.content);
assert.equal(content.width, 1080);
assert.equal(content.height, 1920);
assert.equal(content.duration, 42.5);
const video = convertEventToVideo(event);
assert.equal(video.width, 1080);
assert.equal(video.height, 1920);
assert.equal(video.duration, 42.5);
});
test("a portrait (height>width) video mirrors as 34236 short with imeta dim", async () => {
const { event } = await publish({ videoRootId: "root-2", width: 1080, height: 1920 });
const video = convertEventToVideo(event);
const res = buildNip71MirrorEvent({ ...video, pubkey: PUBKEY });
assert.equal(res.event.kind, NIP71_SHORT_VIDEO_KIND, "portrait => 34236");
const imeta = res.event.tags.find((t) => t[0] === "imeta");
assert.ok(imeta.some((e) => e === "dim 1080x1920"), "carries imeta dim");
});
test("a landscape video mirrors as 34235 normal", async () => {
const { event } = await publish({ videoRootId: "root-3", width: 1920, height: 1080 });
const video = convertEventToVideo(event);
const res = buildNip71MirrorEvent({ ...video, pubkey: PUBKEY });
assert.equal(res.event.kind, NIP71_NORMAL_VIDEO_KIND, "landscape => 34235");
});
test("editing preserves dimensions when the field is omitted", () => {
const { contentObject } = prepareVideoEditPayload({
baseEvent: {
id: "e1",
videoRootId: "root-9",
title: "Old",
url: "https://cdn.example.com/v.mp4",
width: 1280,
height: 720,
duration: 30,
created_at: 1000,
tags: [],
},
originalEventStub: { id: "e1" },
updatedData: { title: "New" },
userPubkey: PUBKEY,
resolveEventDTag: () => "root-9",
});
assert.equal(contentObject.width, 1280);
assert.equal(contentObject.height, 720);
assert.equal(contentObject.duration, 30);
});
+88
View File
@@ -0,0 +1,88 @@
// Hashtags polish: a video's hashtags are persisted in the 30078 content AND
// emitted as `t` tags (so bitvid's feed scoring + relay queries see them), and
// round-trip back through convertEventToVideo onto `video.hashtags` (which the
// NIP-71 mirror builder then carries as `t` tags too).
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import {
sanitizeVideoHashtags,
prepareVideoPublishPayload,
prepareVideoEditPayload,
} from "../js/nostr/videoPayloadBuilder.js";
import { convertEventToVideo } from "../js/nostr/nip71.js";
const PUBKEY = "a".repeat(64);
function publish(extra) {
return prepareVideoPublishPayload(
{ title: "Hello", url: "https://cdn.example.com/v.mp4", ...extra },
PUBKEY,
{ timestamp: 1000 },
);
}
test("sanitizeVideoHashtags: normalizes, dedupes, drops the reserved 'video' tag", () => {
assert.deepEqual(
sanitizeVideoHashtags(["#GameStr", "gamestr", " Voxel ", "video", ""]),
["gamestr", "voxel"],
);
assert.deepEqual(sanitizeVideoHashtags("#ai, blender cartoon"), ["ai", "blender", "cartoon"]);
assert.deepEqual(sanitizeVideoHashtags(null), []);
});
test("publish payload persists hashtags in content AND emits matching `t` tags", async () => {
const { event } = await publish({
videoRootId: "root-1",
hashtags: ["#GameStr", "voxel", "voxel"],
});
const content = JSON.parse(event.content);
assert.deepEqual(content.hashtags, ["gamestr", "voxel"], "stored in content (normalized/deduped)");
const tTags = event.tags.filter((t) => t[0] === "t").map((t) => t[1]);
assert.ok(tTags.includes("gamestr"));
assert.ok(tTags.includes("voxel"));
});
test("round-trip: convertEventToVideo exposes video.hashtags from content", async () => {
const { event } = await publish({ videoRootId: "root-2", hashtags: ["nostr", "p2p"] });
const video = convertEventToVideo(event);
assert.equal(video.invalid, false);
assert.deepEqual(video.hashtags.sort(), ["nostr", "p2p"]);
});
test("no hashtags → no content.hashtags", async () => {
const { event } = await publish({ videoRootId: "root-3" });
const content = JSON.parse(event.content);
assert.equal(content.hashtags, undefined);
});
function edit(baseHashtags, updatedData) {
return prepareVideoEditPayload({
baseEvent: {
id: "e1",
videoRootId: "root-9",
title: "Old",
url: "https://cdn.example.com/v.mp4",
hashtags: baseHashtags,
created_at: 1000,
tags: [],
},
originalEventStub: { id: "e1" },
updatedData,
userPubkey: PUBKEY,
resolveEventDTag: () => "root-9",
});
}
test("editing WITHOUT a hashtags field preserves the existing hashtags", () => {
const { contentObject } = edit(["alpha", "beta"], { title: "New title" });
assert.deepEqual(contentObject.hashtags, ["alpha", "beta"], "not stripped on edit");
});
test("editing WITH hashtags overrides them", () => {
const { contentObject } = edit(["alpha"], { hashtags: ["gamma", "delta"] });
assert.deepEqual(contentObject.hashtags, ["gamma", "delta"]);
});
+59
View File
@@ -0,0 +1,59 @@
// probeVideoMetadata resolves a video's dimensions/duration from a hidden element,
// or null on error/timeout. Uses an injected fake element so it's deterministic.
import assert from "node:assert/strict";
import test from "node:test";
import { probeVideoMetadata } from "../js/utils/videoProbe.js";
// Minimal fake <video>: fires the named event on the next tick when src is set.
function fakeVideo({ fire = "loadedmetadata", videoWidth = 0, videoHeight = 0, duration = 0 } = {}) {
const handlers = {};
return {
videoWidth,
videoHeight,
duration,
addEventListener(name, h) {
handlers[name] = h;
},
removeAttribute() {},
load() {},
set src(_v) {
setTimeout(() => handlers[fire]?.(), 0);
},
};
}
test("resolves width/height/duration from loadedmetadata", async () => {
const result = await probeVideoMetadata("blob:abc", {
createVideoEl: () => fakeVideo({ videoWidth: 1080, videoHeight: 1920, duration: 12.3 }),
});
assert.deepEqual(result, { width: 1080, height: 1920, duration: 12.3 });
});
test("a portrait probe yields height > width (drives short selection)", async () => {
const r = await probeVideoMetadata("blob:abc", {
createVideoEl: () => fakeVideo({ videoWidth: 720, videoHeight: 1280 }),
});
assert.ok(r.height > r.width);
assert.equal(r.duration, 0, "missing duration normalizes to 0");
});
test("resolves null on a media error", async () => {
const r = await probeVideoMetadata("blob:bad", {
createVideoEl: () => fakeVideo({ fire: "error" }),
});
assert.equal(r, null);
});
test("resolves null for an empty source and times out gracefully", async () => {
assert.equal(await probeVideoMetadata("", { createVideoEl: () => fakeVideo() }), null);
// never fires any event -> timeout path
const neverFires = {
addEventListener() {},
removeAttribute() {},
load() {},
set src(_v) {},
};
const r = await probeVideoMetadata("blob:x", { createVideoEl: () => neverFires, timeoutMs: 20 });
assert.equal(r, null);
});
+189 -21
View File
@@ -94,11 +94,38 @@ tests → `npm run build` + `npm run test:unit` green → commit + push.
wrapper, so add carefully — plus a feed/view-level seed→delete→assert test.
(Local + publish/read layers are already unit+mutation tested.)
### 3. Zaps system + platform-fee zap split
- [ ] Audit the zap flow (NWC / `nwcClient.js`, `zapController.js`, `zapReceiptValidator.js`).
- [ ] Verify the platform-fee split: correct recipients, correct percentages, rounding,
and that the fee can't silently swallow the whole zap or be bypassed.
- [ ] Confirm zap-receipt validation (kind 9735) is accurate before crediting.
### 3a. Video-modal popover mis-positioning (zap + embed) — BUG
Confirmed via screenshots: the zap/embed popovers OPEN and are fully functional
(comment box, amount, send all render) but appear anchored to the far LEFT of the
content column instead of `bottom-end` under the button (which is in the right-
side action bar). Both share `js/ui/overlay/popoverEngine.js`.
- Leading cause: the engine only portals a panel into the body-level overlay root
`if (!panel.isConnected)`. The zap/embed panels are pre-existing connected
elements INSIDE the modal, so they're NOT portaled out, and `position:fixed`
resolves against an in-modal containing block (something in the modal subtree
has transform/filter/contain) → horizontal offset.
- Ruled out: positioning CSS present in built css; floating-ui local bundle loads;
panel class correct (`.popover__panel`/`.popover-panel` share the rule).
- Fix (do carefully — shared engine drives every popover): either portal connected
in-modal panels out to `#uiOverlay` on open (and restore/clean on close without
losing the element for re-open), OR remove the containing-block ancestor in the
modal subtree. VERIFY non-modal popovers (card "⋯" menu, feed-settings gear,
FeedInfoPopover) still position correctly after the change.
- To pin exactly: inspect `#modalZapDialog` while open — computed `position`,
`left`, `top`, and its DOM parent (is it under `#uiOverlay` at body level or
still nested in the modal?).
### 3. Zaps system + platform-fee zap split — full plan in docs/zap-audit-plan.md
Audit started 2026-06-23. See the doc for architecture map + findings. Summary:
- [x] **Send-error softened** (`dd1ce113`): recipient LNURL unreachable/CORS now
shows a clear message, not raw "Failed to fetch".
- [ ] **CORS / LNURL proxy decision** (the real reliability fix — static client
can't fetch CORS-less LNURL hosts; many zaps fail). Top of the next session.
- [ ] **Popover positioning (3a)**, **platform-fee split correctness** (incl. the
earlier "fee landed in my own wallet" report + self-zap edge), **receipt
validation (9735)**, **NWC budget/retry UX**, **general clunkiness**.
- [x] Comment box confirmed WORKING (the "message doesn't work" was the popover
mis-position making it hard to use — see 3a).
### 4. View counter accuracy & reliability
- [ ] Audit the view-counter system (`viewEvents.js`, `reactionCounter.js`) for
@@ -106,11 +133,25 @@ tests → `npm run build` + `npm run test:unit` green → commit + push.
double-counting, and resilience to dead relays (now that feed defaults are
reserved, ensure view reads/writes use a sane relay set too).
### 5. Card-hide / video-liveness check
- [ ] Audit the card-hide system that gates a card on a video-liveness probe
(`gridHealth.js`, probe concurrency now 4). Confirm: dead URL/magnet hides the
card correctly, a live video is never falsely hidden, and the probe can't
stampede connections (regression risk from the freeze fix).
### 5. Card-hide / video-liveness check — full plan in docs/video-liveness-plan.md
Audited (2026-06-23). Finding: the hide/show **policy is already correct**
(`cardSourceVisibility.js` hides only when neither CDN nor WebTorrent is healthy;
un-hides when WebTorrent flips green). The real gaps:
- [x] **Speed** (`URL_PROBE_TIMEOUT_MS` 8s → 4s): dead hosts yield a fast verdict
so unplayable cards hide quickly. WebTorrent probe stays generous (swarm).
- [x] **Probe accuracy**: confirmed the card probe already uses the video-element
probe (`confirmPlayable:true`, actually loads media) — accurate, not the
opaque HEAD. No change needed.
- [x] **Multi-source**: ingest adapter exposes `sources` (all video imeta urls;
`collectVideoSources`); `urlHealthController.probeUrlList` probes them in
order — healthy as soon as one plays, offline only if all fail. So a dead
primary host no longer hides a video with a working mirror.
- [ ] **Remaining — player fail-over at play time**: the *liveness* now tries all
sources, but actual playback should also fail over to the next source if the
chosen one dies mid-load (playbackService consumes `video.sources`).
- [ ] **Remaining — hide-until-verified for foreign/ingested** (decision): so
unplayable strangers never briefly flash before the probe hides them
(bitvid-native stays show-pending). UX policy change in cardSourceVisibility.
## Open — medium priority
@@ -131,8 +172,34 @@ tests → `npm run build` + `npm run test:unit` green → commit + push.
surfacing the same control from the Storage settings pane.
### 9. Cap the cold-login relay-REQ storm further
- [ ] Investigate the ~117 REQ/s spike at login. Respect the `capReadRelays` invariant
everywhere fan-out happens; consider staggering subsystem subscriptions at login.
- [x] **Biggest source fixed** (`a8e9f520`): the community-blacklist load fired one
kind-30000 REQ *per curator* (~28-32 parallel) at cold start. Collapsed into a
single batched multi-author REQ (`js/adminListBatch.js`), routed through the
subscription manager. This was the dominant `kind 30000` storm.
- [ ] **VERIFY the batching actually reduced it.** A later logged-OUT load
(2026-06-23 00:45) STILL showed `kind 30000=32` — either that build wasn't
loaded, or there's ANOTHER kind-30000 source besides
`loadCommunityBlacklistEntries` (admin editor/whitelist/blacklist lists? a
per-author follow-set fetch?). Re-check on a fresh hard-refresh; if still ~32,
trace the actual emitter.
- [ ] **`from_hex` source not root-caused.** The sanitizer (`733cb593`) defends +
dev-logs `[toolkit] dropped N invalid hex value(s) from filter "<field>"` —
watch that log to find which subscription ships the odd-length hex, fix at source.
- [x] **Relay `from_hex` REQ rejects fixed** (`733cb593`): one odd-length/non-hex
value in `ids/authors/#e/#p/#q` made strict relays reject a whole REQ; now
sanitized at the pool choke point (`normalizeFilterList`).
- [ ] **Remaining: logged-IN cold-start WoT/social-graph fan-out** (~94-104 REQ/s,
bracketed by `[lists-sync-start]`…`[lists-sync-complete]`). Logged-OUT is now
clean. The spike is per-contact hydration over the user's ~25 follows/trusted
contacts: `kind 30000` (follow sets), `30002` (block lists), `30005/30015`
(interest sets), `10050` (DM relay hints), plus `0` (profiles). NOTE: mutes
(`kind 10000`) are ALREADY batched (moderationService.refreshTrustedMuteSubscriptions,
one multi-author filter). Fix = apply the same multi-author batching to the
other per-contact fetches (userBlocks 30002, hashtagPreferences 30005/30015,
follow-set/contacts hydration 30000, DM 10050) and/or DEFER non-feed-critical
ones (DM relays, interest sets) until after first render. Multi-subsystem +
sensitive (trust) → do as a dedicated perf pass, not a rushed change.
Source still to pin: the loop issuing 30000/30002/30005/30015 per contact.
## Open — lower priority / infra
@@ -248,12 +315,113 @@ Threat model / cautions (write these into the feature + docs):
- [ ] Research: confirm NIP-53 kinds + tag shapes, how zap.stream vs shosho.live
populate `streaming`/`recording`, and HLS playback support in the player.
### 17. Short-form / external NIP-71 video note support
- [ ] Likely **NIP-71 Video Events** — the format Amethyst & co. use for "short
form" vertical video: kind **22** (short video) and kind **21** (normal
video). (Confirm current kinds; NIP-71 churned from the older 34235/34236.)
- [ ] bitvid currently publishes its OWN kind 30078 + attaches NIP-71 metadata.
This item is about CONSUMING external NIP-71 notes (render them in the feed /
a short-form view) so bitvid can show video posted by other Nostr apps.
- [ ] Research: confirm whether short-form is purely NIP-71 kind 22, how it maps
onto bitvid's video model (url/magnet/thumbnail), and the vertical/feed UX.
### 17. NIP-71 interop (full plan in `docs/nip71-migration-plan.md`)
Research done; decisions locked. **See the plan doc** — it supersedes the rough
notes below. Summary:
- Opt-in, off by default. Dual-event: keep canonical kind 30078; add an addressable
**34235/34236** mirror (`d`=`videoRootId`, edits in lockstep). 34235/36 are NOT
deprecated — current NIP-71 designates them for editable content.
- WebTorrent rides standard NIP-94 `imeta` fields (`magnet`, `i`); private videos
never mirrored; HTTPS `url` required to mirror.
- [x] Phase 0 (commit 0e2f683b): `buildNip71MirrorEvent` (js/nostr/nip71Mirror.js)
maps a bitvid video → addressable 34235/36 event; magnet/i/ox added to the
imeta builder+parser; mutation-verified tests. Flag still off, no UX.
- [ ] Phase 1 (in progress):
- [x] 1a (e20191d6): `nip71MirrorService.publish` + the ALLOW_NSFW_CONTENT
gate (instance that forbids NSFW won't mirror it outward).
- [x] 1b (58f19095): `remove` teardown — NIP-09 delete (both addressable
kinds) + empty-replace tombstone.
- [x] 1c/1d (783c7df0): My Videos "Share to apps" opt-in toggle wired to the
mirror service via new FEATURE_NIP71_MIRROR flag (on) +
nip71MirrorFlags (per-video opt-in). Ineligible videos show the reason
(private / NSFW-blocked / no-url). LIVE on unstable.
- [x] Auto-sync (89d9a62b): mirror stays in lockstep on edit/delete,
event-driven via nostrService videos:edited/deleted (no nostrService
growth). Edit re-publishes (or unshares if now-private); delete tears
down + clears the flag.
- [x] Hashtags (a1bf7b15, e793b359): the upload modal's existing NIP-71
hashtag editor already emits `t` tags (feed scoring reads them); the
mirror now carries them too (from t tags / nip71.hashtags). So
publish + feed + mirror hashtags all work.
- [ ] Remaining polish (low value): capture dimensions at upload (short 34236
+ imeta dim — defaults to 34235 now); optional account-level
"auto-share new public videos".
- NOTE: legacy FEATURE_PUBLISH_NIP71 (videoPublisher.js auto-21/22) stays
OFF/dormant — superseded by the opt-in 34235/36 mirror; clean up later.
- [ ] Phase 1.5: NIP-89 handler reg (kind 31990 → "Open in bitvid" elsewhere);
NIP-51 kind 30005 portable playlists.
- [x] Phase 2: inbound ingest of external NIP-71 videos — LIVE on unstable.
- `js/nostr/nip71IngestAdapter.js` (foreign 21/22/34235/34236 → bitvid video;
content-warning→isNsfw; skips bitvid mirrors; surfaces nip71.publishedAt),
`js/services/nip71IngestService.js` (whitelist-scoped subscription,
hydration retry, deferred-until-feed-ready, throttled refresh),
wired in applicationBootstrap. Admin toggle `FEATURE_NIP71_INGEST`
(instance-config) default ON.
- Whitelist model: ingest is scoped to whitelisted authors and reuses the
existing render-time filter (whitelist/NSFW/blacklist/private). When the
admin disables whitelist mode, ingest opens to all authors (capped).
- KEY FIX: the feed's resolve-posted-at stage was firing a per-video
hydrateVideoHistory() network fetch for each ingested video (no kind-30078
history exists) → relay storm + feed hang. Fixed by surfacing
nip71.publishedAt so the stage short-circuits.
- Verified live: Goblinbox (Nostube) content renders; liveness check works.
### 17c. NIP-71 on-boarding + cross-ecosystem dedup (IMPORTANT — full plan in docs/nip71-onboarding-plan.md)
Make NIP-71 interop two-way and guarantee a video is never shown twice.
**See `docs/nip71-onboarding-plan.md` for the full dev plan.** Locked decisions:
creator-initiated self-import (model A — can't forge others' notes), dedup on
explicit import-link + content hash/infohash (no fuzzy url/title), import grants
CDN url + WebTorrent (bitvid extras come free), one-shot import + re-run button.
- [x] **Phase 1: cross-ecosystem dedup** (`js/utils/videoDeduper.js`):
`collapseCrossEcosystem` keyed per-author on `fileSha256/ox/infoHash` + an
`eid` namespace linking on-board provenance (`importedFrom`); prefers the
bitvid (kind-30078) version. Composed into `dedupeVideos` (root-dedup THEN
cross-ecosystem) and routed through the shared chokepoint
`app.dedupeVideosByRoot` → covers the feed stage + channel + search at once.
Pure + mutation-verified. (Hash-less dual-posts still can't dedup — documented
limitation; covered once on-boarded videos carry the provenance link.)
- [ ] **Phase 2: creator-initiated import** — discover the logged-in creator's own
NIP-71 videos lacking a bitvid version; offer FULL import (re-host to THEIR
storage + optional WebTorrent) or REFERENCE import (keep external URL, no
re-host); publish a kind-30078 signed by them with an `["imported-from", <orig>]`
provenance tag. Same-pubkey-only guard; ALLOW_NSFW gate; reuses ingest adapter
+ storage/upload + videoPublisher.
- [ ] **Phase 2b: externally-managed storage flag** — imports NOT produced by our
storage system (reference imports / external URLs) must be explicitly flagged
(`externalStorage: true`, durable — not just the `isUrlUnderBase` heuristic) so
storage tooling never treats them as bitvid-owned: My Videos shows an external
badge (never "missing from bucket"), orphan reconciliation excludes them, the
delete "hosted file left behind" warning is suppressed, liveness stays
unverifiable. Full (re-hosted) imports set the flag false.
### 17d. Unified video-grid loading (streaming + optimistic) — follow-ups
Goal: every grid loads with the same fast UX. Status: cross-ecosystem dedup
(17c Phase 1) is live at the shared chokepoint; main feed already streams +
has a persisted cache; channel/search now render optimistically from
`allEvents` (incl. ingested NIP-71). Remaining:
- [ ] **Streaming channel/search fetch.** `loadUserVideos` does a one-shot
`pMapSettled(relays, pool.list)` and renders only after ALL relays settle,
so a slow relay holds up the grid (~2-3s). Switch to the subscription
manager's streaming + render incrementally (first-relay-wins), like the main
feed's buffered subscription. Extract the fetch into `channelProfileVideos.js`
(channelProfile.js is at its size cap).
- [x] **Per-channel persisted cache** (`channelProfileVideos.js`,
`bitvid:channel-videos:v1`): cold hard-refresh of a profile now paints the
last-seen videos from localStorage before relays connect, then the live fetch
replaces them. Bounded (60 videos/channel, 20 channels LRU), strips raw tags.
Tests + mutation-verified.
- [ ] **My Videos: include NIP-71** in its `allEvents` read so a creator's own
cross-posted videos appear in management too (reuse the cross-ecosystem
dedupe so the bitvid version wins).
- [ ] **Consolidate** the bespoke loaders (channel/search/My Videos) behind one
shared streaming grid source so future grids inherit streaming + optimistic +
dedupe for free.
### 17b. Channel Profile wall shows only bitvid videos, not NIP-71 (LAUNCH-BLOCKER)
- [x] **Fixed** (`js/channelProfileVideos.js`): the channel grid now fetches the
author's NIP-71 videos (kinds 21/22/34235/34236) alongside kind-30078 via
`buildChannelVideoFilters`, and `convertChannelEvent` routes NIP-71 events
through `buildVideoFromNip71Event` (skips bitvid mirrors). Existing
dedupe-by-root + canAccess in `buildRenderableChannelVideos` handle
precedence/gating. Logic extracted to a helper so channelProfile.js stayed
under its size cap. Tests + mutation-verified.