Compare commits

..
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 5 9f20907f35 Merge unstable into main
Brings 14 commits to the production branch, including the BitLogin widget
re-vendored at 205b067 with recorded provenance.

main and unstable had diverged: both independently committed a "security-
audit build" widget update (0592c0ee here, 847a855e there), so the merge
conflicted on six vendor/bitlogin files, including a rename/rename on the
shared chunk -- Vite content-hashes those filenames, so two builds of the
same source produce different names.

Every conflict resolved by taking unstable's side wholesale. Vendored files
are build output, not authored source; a hand-merged bundle would correspond
to no build and no commit. The result was then verified against a clean
rebuild of BitLogin at 205b067 rather than trusted: vendor/bitlogin is
byte-identical to packages/widget/dist from a from-scratch build, the file
set matches origin/unstable exactly, and the manifest reads
provenance=attributed at 205b0678cc67.

main's widget was the OLDER build (0d118d59), predating BitLogin's
second-audit hardening, so this moves production forward rather than back.

Existing sign-ins are unaffected. normalize.ts, argon2id.ts, hkdf.ts and
scalarExpand.ts are byte-identical across this range, so login name plus
password derive the same locator, signing scalar and capsule key. Accounts
created with a hand-typed password still work -- the manual-password removal
took away the creation, recovery and rotation controls, not the acceptance.
What changes: creating, recovering or rotating now issues a generated
six-word passphrase.

Full suite green on the merged tree: 2354 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
2026-07-31 23:22:47 -04:00
thePR0M3TH3ANandClaude Opus 5 fbac54dd48 Re-vendor BitLogin at 205b067, and record its provenance
Updates the vendored widget to BitLogin 205b067, which includes the
second-audit hardening the previous bytes predated -- phrase-gated sudo key,
deterministic migration, the pinned derivation chain -- plus the entropy
work: the closed nonce/IV surface, fail-closed RNG tests, and the
package-wide Math.random ban.

Also ports the provenance recording from BitUnlock's vendoring script. The
manifest now carries repository, commit, commit date and toolchain, and the
script refuses to vendor from a BitLogin checkout with uncommitted changes,
resolved before vendorDir is deleted so the refusal leaves the committed
files intact. Hashes prove the bytes have not changed since they were
committed; only the commit says which BitLogin produced them, and version
"0.1.0" has covered many substantive changes.

EXISTING SIGN-INS ARE UNAFFECTED, which matters most here since this is the
one consumer with real users. normalize.ts, argon2id.ts, hkdf.ts and
scalarExpand.ts are byte-identical between the previously vendored build and
205b067, so login name + password derive the same locator, signing scalar
and capsule key as before. Accounts created with a hand-typed password still
sign in: the manual-password removal took away the creation, recovery and
rotation CONTROLS, not the acceptance, and nothing on the login path
inspects the password's shape. BitLogin's legacyPassword.e2e.test.ts pins
that, including the migration path of signing in with an old typed password
and rotating onto a generated one.

What does change for users: creating a new account, recovering, or rotating
now hands out a generated six-word passphrase instead of accepting a typed
one.

2354 unit tests pass, including the 10 bitlogin-specific ones in
auth-providers-bitlogin and bitlogin-modal-integration.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
2026-07-31 22:17:08 -04:00
thePR0M3TH3ANandClaude Fable 5 58922e55c3 Record the caching decision: content-hash filenames, then immutable
Option A chosen. Noting explicitly that Option B should not ship as an interim
step -- the two are not stackable, and a bounded max-age shipped now is the
thing that never gets revisited.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 14:53:07 -04:00
thePR0M3TH3ANandClaude Fable 5 5e69fa4a7d Todo: static asset caching, and why "just add immutable" would break it
/css/* and /js/* have no header rule at all, so every stylesheet and script
revalidates on every page load. The obvious fix is a trap: asset URLs are not
content-hashed, so a long max-age with immutable would pin returning users to
stale CSS/JS after each deploy with no way to bust it. Records both real
options and the measured live headers.

Also corrects an earlier hand-off note claiming ~10 failing tests here; all
three suites pass on unstable as of today.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 12:31:36 -04:00
thePR0M3TH3ANandClaude Fable 5 8d40d85079 Delete netlify.toml; the site deploys on Vercel
Vestigial. Verified dead before removing: the live headers on
bitvid.network come from vercel.json, not from this file -- /js/* is
served with Vercel's default `max-age=0, must-revalidate` rather than
the `immutable, max-age=31536000` netlify.toml asks for, which is proof
the file is not consulted by anything deploying this site. It cost real
time today by making the deploy target look like Netlify.

Two rules it carried that vercel.json does NOT, both deliberately left
out rather than silently dropped:
- `Content-Security-Policy: frame-ancestors *` on /*. No behaviour
  change: absence is equally permissive, and the embed page relies on
  being framable, not on the header.
- Long-lived immutable caching for /css/* and /js/*. This one is a real
  (small) performance regression that predates this commit -- the live
  site has been serving those with max-age=0 for as long as it has been
  on Vercel. Worth porting into vercel.json separately; deleting a file
  nothing reads is not the change that should carry it.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 10:14:43 -04:00
thePR0M3TH3ANandClaude Fable 5 0592c0eed1 Update the vendored BitLogin widget to the security-audit build
Was on the 2026-07-25 build. Carries an adversarial audit's fixes, the
ones that matter most for a site that hands the widget real identities:

- Origin binding is enforced INSIDE the crypto worker and derived from
  self.location instead of a caller-supplied field, so a page cannot
  read or adopt a wallet connection bound to a different site.
- Phrase recovery no longer trips the rollback alarm on every later
  login (the mark is keyed by the everyday pubkey, which recovery
  preserves, while the recovered capsule restarts at generation 0) --
  users who had ever rotated a password previously saw "possibly
  revoked credentials" forever, with no way to clear it.
- quorumMet counts relays that ANSWERED rather than ones that merely
  accepted a socket, and relay responses are now checked against the
  address requested, so a single junk event can no longer block
  registration, rotation, and recovery.
- Password rotation checks the new capsule reached quorum BEFORE
  publishing the tombstone that destroys the old one; a failed rotation
  used to be able to leave neither password working.
- Derived secrets (Argon2id output, both PRKs, the BIP-39 seed) are now
  wiped at their derivation site, as the spec always claimed.
- The worker RPC validates message shapes, times out, and rejects
  pending calls when the worker dies, so a torn-down worker surfaces as
  an error instead of a permanent hang.
- Synthetic (script-dispatched) clicks no longer drive consent UI.

Verified: the 10 pre-existing test failures in this suite are unchanged
by this update (checked by stashing the vendor directory and re-running
-- 1330 pass / 10 fail either way), and the BitLogin auth-provider
tests pass. dist/ is gitignored build output and regenerates.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 10:05:15 -04:00
thePR0M3TH3ANandClaude Fable 5 847a855ec8 Update the vendored BitLogin widget to the security-audit build
Was on the 2026-07-25 build. Carries an adversarial audit's fixes, the
ones that matter most for a site that hands the widget real identities:

- Origin binding is enforced INSIDE the crypto worker and derived from
  self.location instead of a caller-supplied field, so a page cannot
  read or adopt a wallet connection bound to a different site.
- Phrase recovery no longer trips the rollback alarm on every later
  login (the mark is keyed by the everyday pubkey, which recovery
  preserves, while the recovered capsule restarts at generation 0) --
  users who had ever rotated a password previously saw "possibly
  revoked credentials" forever, with no way to clear it.
- quorumMet counts relays that ANSWERED rather than ones that merely
  accepted a socket, and relay responses are now checked against the
  address requested, so a single junk event can no longer block
  registration, rotation, and recovery.
- Password rotation checks the new capsule reached quorum BEFORE
  publishing the tombstone that destroys the old one; a failed rotation
  used to be able to leave neither password working.
- Derived secrets (Argon2id output, both PRKs, the BIP-39 seed) are now
  wiped at their derivation site, as the spec always claimed.
- The worker RPC validates message shapes, times out, and rejects
  pending calls when the worker dies, so a torn-down worker surfaces as
  an error instead of a permanent hang.
- Synthetic (script-dispatched) clicks no longer drive consent UI.

Verified: the 10 pre-existing test failures in this suite are unchanged
by this update (checked by stashing the vendor directory and re-running
-- 1330 pass / 10 fail either way), and the BitLogin auth-provider
tests pass. dist/ is gitignored build output and regenerates.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 10:02:48 -04:00
thePR0M3TH3ANandClaude Fable 5 5cce3b726a docs: close stale innerHTML TODO — ShareNostrModal has no innerHTML left
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:45:23 -04:00
thePR0M3TH3ANandClaude Fable 5 975b996f4c [nostr-core] Pin the NIP-46 multi-account lifecycle contract (TODO #33)
The 2026-06-25 audit blamed "logout logs out everyone" / broken profile
switching / unknown-user-after-refresh on the single shared nip46Client and
single-slot session storage. Re-auditing the current code shows every named
suspect has since been fixed piecemeal: per-account v2 session map with v1
last-connected default, keepStored logout, scoped no-arg clear (v1 slot only),
reuseStored+expectPubkey switch wired end-to-end into
useStoredRemoteSigner({pubkey}), and a validator-enforced boot restore.

None of that was pinned at the manager level, so any layer could regress
silently. tests/nip46-signer-lifecycle.test.mjs now exercises the real
SignerManager against the real signer registry and real (polyfilled) storage:

- logging out the active NIP-46 account destroys its live client exactly once
  while BOTH accounts' stored sessions and the other account's registry signer
  survive
- a failed targeted restore (passphrase-locked or missing session) fails typed
  and deletes nothing
- disconnectRemoteSigner with and without keepStored never touches the
  per-account map

Mutation-checked: reinjecting the historical bugs (logout without keepStored;
no-arg clear wiping the v2 map) fails 2/6; real code passes 6/6.

TODO #33 updated: remaining work is the on-phone Amber verification the item
already gates on — and if that still misbehaves, the failure is now known to
live ABOVE this contract (UI/state reconciliation on restore failure), which
narrows the search considerably.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:33:26 -04:00
thePR0M3TH3ANandClaude Fable 5 2eb7d09865 docs: note that visual baselines are CI-canonical now
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:23:10 -04:00
thePR0M3TH3ANandClaude Fable 5 e99ba0e817 docs: refresh KNOWN_ISSUES against current test reality
- jsdom entry: resolved long ago (28.1.0 installed, in devDeps) — was still
  marked Active with an install remediation.
- webseed-playbook.spec.ts:31: resolved by f48150ed; documents the actual fix
  (record attached srcs instead of asserting the final cleared src).
- uploadModal-reset: not reproducible across 2026-07-28..30 sweeps; left on
  file with instructions if the ordering-dependent hang recurs.
- login-flows.spec.ts:149: corrected — it is NOT a passes-in-isolation flake;
  it failed in isolation on 2026-07-28 (local + CI e2e-headless) while passing
  in the e2e job. Notes the reverted fix attempt and links it to pre-launch
  TODO #33, which is the same subsystem.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:22:21 -04:00
github-actions[bot] ab120a03e1 test(visual): regenerate baselines on ubuntu-latest
Captured by the update-visual-baselines workflow so the baseline environment matches the visual-regression job that compares against them.
2026-07-30 15:16:54 +00:00
thePR0M3TH3ANandClaude Fable 5 5d0709f5f4 [ci] Fix baseline workflow YAML — literal block was terminated early [update-visual-baselines]
The multi-line git commit message inside the run block dropped below the
literal block's indentation, ending the block mid-command and failing the
whole workflow file at parse time (0s failure, no jobs). Use two -m flags on
one shell line instead. YAML now validated locally before push.

Token included so this push performs the regeneration.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:15:26 -04:00
thePR0M3TH3ANandClaude Fable 5 350feae907 [ci] Allow triggering baseline regeneration from unstable [update-visual-baselines]
workflow_dispatch only works for workflows on the default branch, which this
file will not reach until the next promotion. Gate the job on an explicit
[update-visual-baselines] commit-message token instead so it is dispatchable
from unstable now; the workflow's own commit omits the token, so it cannot
retrigger itself.

This commit carries the token, so pushing it performs the first regeneration.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:14:17 -04:00
thePR0M3TH3ANandClaude Fable 5 31be84ba82 [ci] Add a workflow to regenerate visual baselines on the CI runner
The visual-regression job pixel-compares tests/visual/baselines.json on
ubuntu-latest, but the committed baselines were captured on developer machines.
Chromium builds and font fallbacks differ between environments — the token
stack's "Inter"/"Fira Code" are not shipped or installed anywhere, so every
machine renders its own system fallback — and the kitchen-sink default-theme
diff drifted past MAX_DIFF_RATIO on CI while passing 13/13 locally. ci.yml
documents exactly this mismatch as the reason the job is quarantined from
blocking deploys.

The CI diff artifact confirms it: baseline and actual render identically except
for ghosted/offset text in the high-contrast lower half — font-metric drift,
not a design change. (The dark upper half diffs too little to trip the 0.2
pixelmatch threshold, which is why only the default theme fails.)

Fix the class of problem instead of the instance: capture baselines in the SAME
runner image, browser install and font stack that the comparison job uses.
workflow_dispatch only — regenerating baselines is an explicit decision made
when a visual change is intentional, never a side effect of a push. The
workflow re-runs the comparison against its own output before committing, so a
bad capture cannot land.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:11:14 -04:00
thePR0M3TH3ANandClaude Opus 5 f48150ed6b [ci] Fix the stale tests that had CI red
CI has been failing on unstable for weeks, across four unit files and a set of
e2e specs. None of it was a product regression: every failure is a test that
encodes behavior a later commit deliberately changed. Each is corrected against
the spec that superseded it, not adjusted until it passed.

Unit:

- video-modal-moderation: the fixture set flat `blurThumbnail`/`trustedMuteCount`,
  but normalizeVideoModerationContext derives shouldShow from
  `original.blurThumbnail`/`trustedCount`, so the assertion ran against a
  context whose early return had just deleted the blur it was checking. 4525c1c6
  also made a lone trusted mute a non-blocking ranking signal (its sibling test
  was renamed to say exactly that; this one was missed). Fixture now mirrors
  what moderationDecorator emits.
- trustBootstrap: asserted a subscription to onWhitelistChange that 18e08bf2
  deliberately removed -- buildTrustedSeeds derives seeds from the super admin,
  editors and fallback seeds, so a whitelist change cannot alter them. The
  removal is now pinned so it is not reinstated by reflex.
- moderation-service: 18e08bf2 removed `status.whitelisted ||` from
  isTrustedReporter (being allowed to publish is not a statement that your
  reports carry weight). A whitelisted reporter now counts toward the report
  total but not toward trusted.
- playbackService_forcedSource: forced P2P now falls back to a hosted URL when
  the torrent definitively fails -- playbackService documents the change and its
  rollback. The old assertion did not merely fail, it HUNG: forced sources skip
  the start timeout, so the now-reachable hosted attempt waited forever on a
  <video> that never emits.

E2E:

- ui-modals: asserted exactly one `.bv-modal` per component page, but
  profile-modal.html also bundles #storageCorsModal (d7ad1516). Requires at
  least one and drives the primary, which is what the test's own injected
  querySelector already used.
- coverage-utility-modules: used `author:abc123`, which 9afbc0cb correctly began
  rejecting as undecodable. Uses a real pubkey for the success path and pins the
  rejection separately.
- webseed-playback: asserted the FINAL src equals the CDN URL while also
  tolerating "No playable source found" in its status check -- contradictory,
  since a failed attempt calls resetVideoElement() and clears src. Headless CI
  cannot decode the archive.org H.264 sample. Now records src attachments and
  asserts the URL was attached, which is the behavior under test.
- nostr-runtime-coverage: asserted a legacy plaintext session-actor entry reads
  back as null. Since 8692dac6 added per-account storage, readV1Entry PURGES the
  plaintext entry and the reader falls back to the sole remaining encrypted
  account -- so null no longer describes the security property. Asserts the
  property directly: no plaintext key survives, in the entry or in raw storage.

Also drops the syncAuthUiState share-gate wiring added in d141c316. It was
redundant: Application.updateShareNostrAuthState() already fires on init, login,
logout, pubkey-change and signer-change and forwards to
videoModal.setShareNostrAuthState(). That call short-circuited on its own
`if (!this.videoModal?.setShareNostrAuthState) return;` guard purely because the
method did not exist -- adding it (d141c316) was the whole fix. The tests now
pin that contract instead of a second, competing code path.

All 416 unit files, all three CI shards, both DM suites and lint are green.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 17:37:24 -04:00
thePR0M3TH3ANandClaude Opus 5 244f527cf6 [share] Put author and relay hints in the shared nevent
buildShareUrlFromEventId encoded a BARE event id: no author, no relay hints. A
viewer whose relay set did not happen to carry that event could not resolve it,
so bitvid fell back to the home feed -- the "the shared link just opens
bitvid.network" report.

It is not a rare edge case. The reported video was present on nos.lol but
absent from relay.damus.io, relay.snort.social and relay.primal.net -- three of
bitvid's four bootstrap relays -- so most viewers arriving cold could not open
it at all.

The pointer now carries:

- the video's author, pulled from videosMap, which also lets clients look the
  event up by author+kind if the id itself has aged out of a relay
- up to three relay hints from the client's write relays (falling back to its
  general relay list), deduped and scheme-filtered

Both are best-effort: an unknown video or a malformed pubkey still produces the
old bare-id pointer rather than an invalid one, and no relays configured omits
the hint instead of encoding an empty array.

Also restores the path separator. The base URL has its trailing slash stripped,
so shared links read `https://bitvid.network?v=...`. That is legal and browsers
normalize it, but it looks like a typo in a note and some link parsers
mis-detect where the URL ends.

New tests fail 6/9 without the change.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 16:08:34 -04:00
thePR0M3TH3ANandClaude Opus 5 99f8e88969 [playback] Don't yank blocked autoplay into WebTorrent
On mobile a bitvid share link opens with no user gesture, so the browser
refuses autoplay. bitvid handled that correctly -- it showed "Press play to
start the hosted video." and armed a no-stall watchdog to wait -- but the 3s
start timeout kept running underneath and fired anyway. For a video with both a
hosted URL and a magnet that meant:

  Press play to start the hosted video.
  Switching to WebTorrent...

The element was reset out from under the prompt the viewer was about to act on,
and the swarm (often seedless, the webseed being the very URL we just
abandoned) played nothing. Desktop usually hides this because an established
Media Engagement score lets autoplay through, so the same link plays there.

A blocked autoplay is not a stalled source. Suspend the start timeout while we
are waiting on a user gesture and let the attempt settle on its own -- it
resolves when they press play, or when a real media error or stall fires. The
guard is cleared on the `play` gesture so a genuine stall afterwards still
fails over normally.

Reproduced on an emulated phone against the reported video
(v.nostr.build/1eNLjw5el05Uc3KU.mp4, magnet with a webseed): before, statuses
end "Press play..." then "Switching to WebTorrent..." with the P2P path
entered; after, they end at "Press play..." and playback starts on tap. The new
tests fail 2/4 without the guard.

Note for future test authors: createSession() overwrites any
playbackStartTimeout passed in its options with the service's value, so a
per-session override is silently ignored -- set it on the PlaybackService
constructor or the test races the real 3s default and proves nothing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 15:26:44 -04:00
thePR0M3TH3ANandClaude Opus 5 c0d006a5fc [og] Derive the preview origin from the request
SITE_ORIGIN is a constant defaulting to https://bitvid.network, so the unstable
deployment advertised production's canonical URL and, worse, a twitter:player
pointing at production's embed -- testing a preview link silently exercised the
live site.

Build shareUrl, embedUrl and the fallback card from the request's own
proto+host instead, so every deployment (production, unstable, preview) is
self-consistent. BITVID_SITE_ORIGIN remains the fallback when the host header
is absent.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:48:32 -04:00
thePR0M3TH3ANandClaude Opus 5 04075ff56b [og] Route crawlers via middleware, not a rewrite
The OG function deployed and worked (`/api/og?v=<nevent>` returns the real
title, description, thumbnail and player card), but crawlers hitting
`/?v=<nevent>` still got the site-wide card: Vercel evaluates `rewrites` AFTER
the filesystem, and `/` matches the static index.html, so the rewrite never
fired.

Middleware runs before the filesystem check, so that is where the routing
belongs. Replace the dead `rewrites` block with a root middleware that matches
only `/`, bails immediately when there is no `v` param or the user-agent is not
a known preview crawler, and otherwise rewrites to /api/og.

Real viewers are unaffected -- they fall through to the untouched SPA. Note the
one behavioral difference from the rewrite design: middleware now executes on
homepage requests (matcher is `/` only) rather than being invoked solely for
crawlers. It returns next() after two cheap checks.

Verified on the live unstable deployment that /api/og already renders
`x-bitvid-og: video` with og:type video.other for the real note; this only
fixes how crawlers reach it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:40:20 -04:00
thePR0M3TH3ANandClaude Opus 5 e5784893a8 [build] Drop the stale pnpm-lock.yaml that was failing deploys
Adding api/ made Vercel run a second install phase to bundle the serverless
function. That phase auto-detects the lockfile and ignores the project's
`installCommand: npm install`, so it picked pnpm and failed:

  ERR_PNPM_OUTDATED_LOCKFILE  Cannot install with "frozen-lockfile" because
  pnpm-lock.yaml is not up to date with package.json

The static build itself succeeded every time -- version injected, deploy
artifact verified -- but the failed function install aborted the whole
deployment, so unstable kept serving the last good build from two days prior.

pnpm-lock.yaml has not been touched since 2026-02-08 and was already stale by
7 added and 4 mismatched dependencies BEFORE this branch (bitcoin-connect,
blossom-client-sdk, cssnano, nostr-wasm, postcss-cli, postcss-import, esbuild,
nostr-tools, jsdom, stylelint); hls.js merely joined the list. It never
mattered because nothing installed from it until now.

Nothing references pnpm: package.json scripts, .github/workflows and
CONTRIBUTING all use npm, and vercel.json already pins
`installCommand: npm install`. package-lock.json is the maintained lockfile.
Keeping a second, unmaintained one only guarantees the two disagree again.

Restore with `git revert` (and regenerate it with `pnpm install
--lockfile-only`) if pnpm is ever adopted deliberately.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:31:01 -04:00
thePR0M3TH3ANandClaude Opus 5 d141c316a9 [ui] Make "Share on Nostr" a working button
The share menu's "Share on Nostr" entry was never clickable, no matter who was
logged in -- it rendered as inert, dimmed text.

VideoModal initialized `shareNostrAuthState` to { isLoggedIn: false, hasSigner:
false } and nothing ever wrote to it. createVideoShareMenuPanel gates the item
on `isLoggedIn && hasSigner`, so it was always disabled, and the click
delegation in bindShareMenuActions() bails on `button.disabled` -- so the whole
share-to-Nostr flow behind it was unreachable.

The renderer and the publish path were both already correct; only the gate was
missing:

- VideoModal.setShareNostrAuthState() records the state and closes an already
  open share menu when it changes, so a menu opened before login does not keep
  showing the stale disabled item. It is a no-op when nothing changed, to avoid
  slamming the menu shut on every sync.
- syncAuthUiState() computes the gate from isUserLoggedIn() plus a
  signing-capable active signer, mirroring ShareNostrController.handleShare():
  a signer without signEvent would only produce an error toast on click.
- syncAuthUiState() was only ever called at bootstrap, so logging in
  mid-session left the gate closed. It now self-installs a bitvid:auth-changed
  listener on first run (bound once, re-entrancy safe) rather than adding a
  call site in js/app.js, which is at its file-size cap.

Verified in a browser that the rendered item is disabled when logged out or
signer-less and enabled + click-dispatching with both; the new tests fail 5/9
with the gate push removed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:16:18 -04:00
thePR0M3TH3ANandClaude Opus 5 b84558e928 [og] Render per-video OpenGraph tags for crawlers
index.html carries one set of site-wide OG tags, so every /?v=<nevent> link
previewed as the same generic bitvid card -- never the video's title or
thumbnail. bitvid is a static SPA and crawlers do not run JS, so the client
cannot fix this.

api/og.js renders the tags on demand. Nothing is pre-generated and nothing is
stored: the video is a query param, not a path, so one stateless function
serves every video and dist/ is byte-identical with or without it. The only
caching is the CDN edge (s-maxage=300) plus a per-container memo of the admin
lists.

This is never load-bearing. Only crawler user-agents are rewritten to it
(vercel.json), humans get a 302 to the untouched SPA, and a static/nsite deploy
with no functions keeps working -- it just serves the site-wide card. Rollback
is deleting the `rewrites` block.

- Emits twitter:player / og:video:url pointing at embed.html?pointer=<nevent>,
  so a shared link plays inline on Twitter/Discord/Telegram rather than showing
  a still. frame-ancestors * already permits the embed.
- Fails closed. A crawler never authenticates, so anything emitted is public
  forever in third-party caches: deleted, isPrivate, isNsfw/content-warning,
  blacklisted authors, and -- while whitelist mode is on -- an unavailable or
  empty whitelist all fall back to the generic card.
- Escapes every attribute. Titles come from arbitrary nostr events and this HTML
  is served from bitvid's own origin; only http(s) URLs reach a meta tag.
- Parses BOTH event shapes: bitvid's kind-30078 v3 JSON and the NIP-71 tag shape
  (kinds 21/22/34235/34236) that nostube/slidestr publish, where the title is a
  `title` tag and the thumbnail an `imeta image` entry. Reading only the JSON
  shape made every NIP-71 video fall back to the generic card.
- Relay budget is 4s: a cold pool's first read measured ~3s, and 1.5s silently
  degraded every preview. Warm containers answer in <600ms.

Admin-list identity and the relay set are imported from js/config.js,
js/nostr/toolkit.js and js/nostrEventSchemas.js rather than duplicated -- a
hardcoded copy of the namespace gated every preview during development, and a
test now fails if any npub or wss:// literal reappears in the handler.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 13:59:25 -04:00
thePR0M3TH3ANandClaude Opus 5 38b51f13a5 [share] Give share-to-Nostr notes a real preview
"Share on Nostr" published a kind-1 note whose body ended with a bare thumbnail
URL. Many clients only inline such a URL when a NIP-92 `imeta` entry describes
it, so the viewer often saw a plain link instead of a preview image.

- buildShareEvent now emits an `imeta` entry for the thumbnail. The media type
  is inferred from the extension and OMITTED when unknown -- a wrong `m` is
  worse than a missing one, since clients use it to decide whether to render
  inline at all, so extensionless Blossom URLs ship url + alt only.
- inferImageMimeTypeFromUrl lives in a SEPARATE map from EXTENSION_MIME_MAP:
  that map gates video upload/publish paths, and folding image types into it
  would let an image pass a video check.
- shareNostrController was passing only { id, pubkey } to buildShareEvent, so
  the tag could never be built; it now forwards title and thumbnail.

When a video has a NIP-71 mirror, the note instead quotes it with
`nostr:naddr1...`, which nostr clients render as a native video quote card --
the closest thing to a rich preview that does not depend on any client fetching
OpenGraph tags (most never do).

- The mirror is addressable (kind 34235/34236, author = video pubkey,
  d = videoRootId), so js/nostr/mirrorPointer.js derives the coordinate with no
  relay lookup, and the pointer survives the author editing the video where a
  plain `nevent` would rot on the next replace.
- The pointer is only embedded after nip71MirrorService.findMirror() confirms
  the mirror exists. A pointer to a missing event renders as a broken/empty
  quote box, which is worse than the plain-link fallback, so a disabled
  service, a throwing lookup, a 2.5s timeout or a missing videoRootId all fall
  back to the thumbnail note.
- resolveMirrorKind prefers the kinds actually observed on relays and only then
  falls back to the same portrait rule the mirror builder uses, so a derived
  pointer matches what would be published rather than guessing independently.
- While quoting, the raw thumbnail URL is dropped from the body and the imeta
  tag is skipped: the quote card already shows the thumbnail, and emitting both
  rendered the same image twice. An `a` tag links the note to the coordinate.

Known limitation: a client rendering the quote card still has to play the
video, so mirrors whose imeta url is an .m3u8 hit the same HLS wall -- title and
thumbnail render, playback depends on the client.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 13:59:10 -04:00
thePR0M3TH3ANandClaude Opus 5 4404728410 [playback] Add HLS support and stop blaming stale MediaErrors
bitvid attached every hosted source with `videoEl.src = url`, which only plays
HLS on Safari-family browsers. Firefox 146 fails with MEDIA_ERR_SRC_NOT_SUPPORTED
(canPlayType returns ""), so every .m3u8 note -- e.g. anything published by
nostube/slidestr -- was unplayable there and the URL-health badge showed
"offline". Chrome/Brave 149 happen to play HLS natively, which is why this
looked browser-specific.

Vendor hls.js (npm run build:hls) and route HLS sources through it:

- js/services/hlsPlayback.js owns detection, engine choice and the hls.js
  lifecycle. Engine choice keys off MediaSource, NOT canPlayType: Chrome
  returns "maybe" for every HLS type, so a native-first branch would hang.
  Native is used only when MSE is genuinely unavailable.
- The bundle is the FULL build, not hls.js/light -- light drops the alternate
  audio stream controller and real ladders demux audio into an EXT-X-MEDIA
  group. It is lazy-imported only when a .m3u8 is encountered, so ordinary
  MP4/WebTorrent playback never pays the ~500KB.
- modalCoordinator tears the instance down first; clearing `src` alone leaves
  the transmuxing worker and segment fetches running after the modal closes.
- urlHealthController probes HLS by fetching the playlist for #EXTM3U. The old
  bare-<video> probe could not load it and on Chrome emitted no events at all,
  burning the full timeout on every HLS card.
- HLS_PLAYBACK_START_TIMEOUT widens the 3s start window, which fired
  mid-startup: the bundle, master playlist, variant playlist, init segment and
  first media segment are four-plus serial round trips.

Also fixes two bugs found while verifying this:

- A stale MediaError was reported as the current failure. Teardown clears `src`
  and calls load(), leaving MEDIA_ERR_SRC_NOT_SUPPORTED ("Empty src attribute")
  on the element; a later watchdog fallback rendered that as "Hosted playback
  failed: source not supported or blocked." over a stream that was playing
  fine. Only a MediaError produced by the current attempt is surfaced now.
  Sources are also bound before the watchdogs are armed, so hls.js's own
  attachMedia() src swap cannot read as a playback failure.
- `audio/x-mpegurl` is a legacy alias for an HLS playlist of any kind, not an
  audio-only assertion. isAudioOnlyVideoObject misfiled HLS video ladders
  published with it as podcasts and dropped them from every video feed.

Verified end-to-end in Chromium, Chrome 149, Brave 149 and Firefox 146 against
a live HLS stream: Firefox goes from "No playable source found." to playing.
Rollback: stop calling attachHlsSource() and the previous `src` assignment
returns; see docs/playback-fallback.md.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 13:58:55 -04:00
thePR0M3TH3AN 726c0d26ed Update performance plan progress 2026-07-26 18:41:34 -04:00
thePR0M3TH3AN bb6614e3d3 Render cached feeds before recommendation refresh 2026-07-26 18:38:03 -04:00
thePR0M3TH3AN 687f8c438d Optimize feed lifecycle and navigation rendering 2026-07-26 18:27:57 -04:00
thePR0M3TH3AN 2c4aefe872 Document channel follower count scope 2026-07-26 17:59:50 -04:00
thePR0M3TH3AN b774de6d76 Harden profile data loading 2026-07-26 17:59:50 -04:00
thePR0M3TH3AN 5f439d6863 Lazy-load recommendation indexes 2026-07-26 17:59:45 -04:00
thePR0M3TH3AN fe976b3ab7 Harden Nostr startup performance 2026-07-26 17:20:52 -04:00
96 changed files with 29587 additions and 5285 deletions

No files matched your search

@@ -0,0 +1,95 @@
# Regenerate the visual-regression baselines IN the environment that compares
# them.
#
# The baselines in tests/visual/baselines.json are pixel-compared on
# ubuntu-latest CI, but they were historically captured on a developer machine.
# Chromium builds and font fallbacks differ between the two ("Inter"/"Fira
# Code" are not shipped or installed anywhere, so every environment renders its
# own system fallback), so the kitchen-sink diff drifted past MAX_DIFF_RATIO on
# CI while passing locally — ci.yml even documents the mismatch as the reason
# visual-regression must not block deploys. Capturing the baselines on the same
# runner image, browser install and font stack as the comparison removes the
# environment variable from the equation entirely.
#
# Two explicit triggers, both requiring deliberate human intent — regenerating
# baselines must never be a side effect of ordinary work:
#
# 1. workflow_dispatch — only usable once this file reaches the default
# branch (GitHub does not index dispatchable workflows on other branches).
# 2. A push whose head commit message contains [update-visual-baselines] —
# works from unstable today, before any promotion. The workflow's own
# commit omits the token, so it cannot retrigger itself.
name: Update visual baselines
on:
workflow_dispatch:
push:
branches: [unstable]
permissions:
contents: write
jobs:
update-baselines:
if: >-
github.event_name == 'workflow_dispatch' ||
contains(github.event.head_commit.message, '[update-visual-baselines]')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Node.js 22
uses: actions/setup-node@v5
with:
node-version: 22
# Mirror the visual-regression job's caches so the regenerated baselines
# come from the same dependency tree the comparison job will use.
- name: Cache npm directory
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-22-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-22-npm-
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-22-modules-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-22-modules-
- name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Regenerate baselines
run: npm run test:visual:update
- name: Verify the fresh baselines pass in this environment
run: npx playwright test --project=visual
- name: Commit updated baselines
run: |
if git diff --quiet -- tests/visual/baselines.json; then
echo "Baselines unchanged; nothing to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add tests/visual/baselines.json
git commit \
-m "test(visual): regenerate baselines on ubuntu-latest" \
-m "Captured by the update-visual-baselines workflow so the baseline environment matches the visual-regression job that compares against them."
git push
+26 -14
View File
@@ -3,11 +3,10 @@
## Test Infrastructure
### Missing `jsdom` Dependency
- **Status:** Active
- **Status:** Resolved (2026-07-30)
- **Detected:** 2026-02-26
- **Description:** Multiple unit tests fail with `ERR_MODULE_NOT_FOUND` because `jsdom` is imported but not installed in the project.
- **Impact:** Prevents running unit tests that rely on DOM simulation.
- **Remediation:** `npm install --save-dev jsdom`
- **Description:** Unit tests failed with `ERR_MODULE_NOT_FOUND` because `jsdom` was imported but not installed.
- **Resolution:** `jsdom` has been a devDependency for some time (28.1.0 installed and in `package.json`); the jsdom-based suites run clean.
## Run Notes (2026-03-04)
@@ -22,19 +21,32 @@
- **Description:** `tests/nostr-send-direct-message.test.mjs` must pass `Uint8Array` secret keys to `getPublicKey/finalizeEvent` for current `nostr-tools`.
- **Impact:** Passing hex strings causes failures like `expected Uint8Array, got type=string`.
## Run Notes (2026-06-20)
## Run Notes (2026-06-20, updated 2026-07-30)
### Additional E2E specs flake under full parallel load (pass in isolation)
### `login-flows.spec.ts:149` — upload/profile buttons stay visible after logout
- **Status:** Active — NOT merely a parallel-load flake
- **Description:** Previously filed as a worker-contention flake that "passes deterministically in isolation." On 2026-07-28 it failed **in isolation** as well (chromium, local and CI `e2e-headless`), while passing in the CI `e2e-tests (e2e)` job — environment-sensitive, not load-sensitive. Direct probes of the same flow (raw `__bitvidTest__.loginWithNsec` → `logout()` against a mock relay) hide the button correctly, so the failure is specific to something in the Playwright fixture path. An attempted fix (running `applyLoggedOutUiState()` before the awaited NWC/profile teardown in `_executeAuthLogout`) did not change the outcome and was reverted rather than shipped unverified.
- **Impact:** One e2e failure in `e2e-headless`; logout hides gated UI correctly in manual testing and direct harness probes.
- **Next step:** Investigate alongside pre-launch TODO #33 ("logout logs out everyone" / NIP-46 session persistence) — same subsystem.
### `video-upload-publish-lifecycle.spec.ts:49` / `:79` flake under full parallel load
- **Status:** Active
- **Description:** Under the full `test:e2e` run (378 tests, 4 workers) these intermittently fail on `not.toBeVisible`/feed-appearance timeouts, but pass deterministically when re-run in isolation (`--workers=1`): `login-flows.spec.ts:149` (upload/profile buttons hide after logout), `video-upload-publish-lifecycle.spec.ts:49` and `:79` (URL-first publish appears / owner delete). Same root cause as the Firefox feed-hydration flakes above — worker resource contention, not an app regression (verified: pass in isolation on the current branch).
- **Impact:** Full-suite e2e is flaky; isolated reruns are green.
- **Description:** Under the full `test:e2e` run these intermittently fail on feed-appearance timeouts but pass on retry (CI marks them flaky). Worker resource contention.
- **Impact:** Full-suite e2e occasionally needs retries; isolated reruns are green.
### `webseed-playback.spec.ts:31` fails on Firefox headless (CDN mode)
- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`)
- **Description:** "plays directly from HTML link (CDN mode)" fails on `--project=e2e-firefox` with `#modalVideo` `src` empty instead of the test's archive.org URL. The Chromium variant and the WebTorrent-fallback variant (`:63`) both pass. Firefox-headless-specific (external URL reachability/codec), not an app regression.
- **Impact:** One Firefox-only e2e failure; CDN playback works on Chromium and in manual testing.
- **Status:** Resolved (2026-07-28, `f48150ed`)
- **Description:** The test asserted the **final** `src` equals the CDN URL while simultaneously tolerating "No playable source found" in its status assertion — contradictory, because a failed hosted attempt calls `resetVideoElement()`, which clears `src`. Headless CI cannot decode the archive.org H.264 sample, so the cleared-src path was routine there.
- **Resolution:** The test now records every `src` the player attaches via a MutationObserver and asserts the CDN URL was attached — the actual behavior under test. Green in both `e2e` and `e2e-firefox` CI jobs.
### `uploadModal-reset.test.mjs` ("UploadModal Reset Logic") hangs/cancels
- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`)
- **Description:** The jsdom-based UploadModal reset suite intermittently fails with `cancelledByParent` / "Promise resolution is still pending but the event loop has already resolved" (~12s hang). It passes or fails depending on full-suite test ordering/timing (an async-hang flake, likely a torrent-metadata/upload promise not settling under the mocked services). Not a regression from the MediaUploader extraction — it fails identically on `1b11cb1b`.
- **Impact:** One unit suite can report `not ok 1`; the runner still exits 0. Other tests unaffected.
- **Status:** Not reproducible (2026-07-30)
- **Description:** Intermittent `cancelledByParent` / "Promise resolution is still pending" hang, order/timing dependent.
- **Current state:** Passes cleanly (`pass=3 fail=0 cancelled=0`) in isolation and across repeated full-suite sweeps on 2026-07-28–30. Left on file because the original failure was ordering-dependent; if it recurs, capture the full-suite ordering that produced it.
## Run Notes (2026-07-30)
### Visual baselines are CI-canonical — local `test:visual` may fail the kitchen-sink diff
- **Status:** By design (since `ab120a03`)
- **Description:** `tests/visual/baselines.json` is regenerated on ubuntu-latest by the `update-visual-baselines` workflow (trigger: `workflow_dispatch` once promoted, or a commit containing `[update-visual-baselines]`). Baselines can only match one environment, and the CI gate is the one that matters — so a dev machine with different Chromium/font rendering may now fail `npm run test:visual` locally (typically the kitchen-sink default theme) even though CI is green.
- **Workaround:** Run visual tests in the pinned container via `scripts/run-playwright-docker.sh`, or rely on CI. Never regenerate baselines from a dev machine.
+363
View File
@@ -0,0 +1,363 @@
// Per-video OpenGraph / Twitter-player rendering for crawlers.
//
// bitvid is a static SPA: index.html carries one set of site-wide OG tags, so
// every `/?v=<nevent>` link previewed as the same generic bitvid card. Crawlers
// don't run JS, so the client can't fix that — this module renders the tags a
// crawler needs, and a thin host adapter (api/og.js on Vercel) feeds it.
//
// PURE by design: no network, no host globals. All I/O lives in the adapter so
// the escaping and gating rules below stay unit-testable.
//
// Rollback: remove the crawler rewrite from vercel.json. The static site keeps
// serving its existing tags and nothing else changes — this is never on the
// path of a real user's page load, and `npm run archive` (nsite) is unaffected.
// Player card dimensions. Twitter refuses a player card without explicit
// dimensions, and 16:9 matches bitvid's embed shell.
export const DEFAULT_PLAYER_WIDTH = 1280;
export const DEFAULT_PLAYER_HEIGHT = 720;
const MAX_TITLE_LENGTH = 120;
const MAX_DESCRIPTION_LENGTH = 200;
/**
* Escape a value for use inside a double-quoted HTML attribute.
*
* Titles and descriptions come from arbitrary nostr events — treat every one as
* hostile. A title of `"><script>…` would otherwise break out of the meta tag
* and inject markup into a page we serve from our own origin.
*/
export function escapeHtmlAttribute(value) {
if (typeof value !== "string") {
return "";
}
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* Only http(s) URLs may reach a meta tag. Anything else (javascript:, data:,
* relative paths) is dropped — a crawler resolving them is at best useless and
* at worst a vector.
*/
export function sanitizeAbsoluteUrl(value) {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
return "";
}
let parsed;
try {
parsed = new URL(trimmed);
} catch (err) {
return "";
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return "";
}
return parsed.toString();
}
/** Collapse whitespace and hard-truncate for preview surfaces. */
export function normalizeText(value, maxLength) {
const collapsed =
typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
if (!collapsed || !Number.isFinite(maxLength) || maxLength <= 0) {
return collapsed;
}
if (collapsed.length <= maxLength) {
return collapsed;
}
return `${collapsed.slice(0, maxLength - 1).trimEnd()}…`;
}
/**
* Decide whether a video may be described in a link preview.
*
* A crawler never authenticates, so anything emitted here is fully public and
* outlives us in third-party caches. The rule is fail-closed: only a video that
* is affirmatively public gets a rich card; everything else — including the
* "we couldn't determine it" case — falls back to the generic bitvid card.
*
* @returns {{allowed: boolean, reason: string}}
*/
export function isPubliclyShareable(video, options = {}) {
if (!video || typeof video !== "object") {
return { allowed: false, reason: "missing-video" };
}
if (video.invalid) {
return { allowed: false, reason: "invalid-video" };
}
if (video.deleted === true) {
return { allowed: false, reason: "deleted" };
}
if (video.isPrivate === true) {
return { allowed: false, reason: "private" };
}
// NSFW thumbnails must never be injected into an unsuspecting timeline.
if (video.isNsfw === true) {
return { allowed: false, reason: "nsfw" };
}
const pubkey =
typeof video.pubkey === "string" ? video.pubkey.trim().toLowerCase() : "";
if (!pubkey) {
return { allowed: false, reason: "missing-author" };
}
const blacklist = options.blacklist instanceof Set ? options.blacklist : null;
if (blacklist && blacklist.has(pubkey)) {
return { allowed: false, reason: "blacklisted" };
}
if (options.whitelistEnabled) {
const whitelist =
options.whitelist instanceof Set ? options.whitelist : null;
// No usable whitelist while whitelist mode is on means we cannot prove the
// author is allowed. Fail closed rather than leak a gated video's metadata.
if (!whitelist || whitelist.size === 0) {
return { allowed: false, reason: "whitelist-unavailable" };
}
if (!whitelist.has(pubkey)) {
return { allowed: false, reason: "not-whitelisted" };
}
}
return { allowed: true, reason: "ok" };
}
// bitvid's own notes are kind 30078 with a v3 JSON body. Other clients
// (nostube/slidestr and anything NIP-71 native) publish kind 21/22/34235/34236
// with the metadata in TAGS and the description in `content`. bitvid normalizes
// both in the browser, so the preview must understand both too — parsing only
// the JSON shape made every NIP-71 video fall back to the generic card.
export const NIP71_VIDEO_KINDS = Object.freeze([21, 22, 34235, 34236]);
function firstTagValue(tags, name) {
if (!Array.isArray(tags)) {
return "";
}
for (const tag of tags) {
if (Array.isArray(tag) && tag[0] === name && typeof tag[1] === "string") {
const value = tag[1].trim();
if (value) {
return value;
}
}
}
return "";
}
function hasTag(tags, name) {
return (
Array.isArray(tags) &&
tags.some((tag) => Array.isArray(tag) && tag[0] === name)
);
}
/** First `image <url>` entry inside any imeta tag. */
function imetaImageUrl(tags) {
if (!Array.isArray(tags)) {
return "";
}
for (const tag of tags) {
if (!Array.isArray(tag) || tag[0] !== "imeta") {
continue;
}
for (let i = 1; i < tag.length; i += 1) {
const entry = typeof tag[i] === "string" ? tag[i].trim() : "";
if (/^image\s+/i.test(entry)) {
const url = sanitizeAbsoluteUrl(entry.replace(/^image\s+/i, ""));
if (url) {
return url;
}
}
}
}
return "";
}
/**
* Normalize a raw nostr event into the fields the preview needs, for both the
* bitvid v3 JSON shape and the NIP-71 tag shape. Returns null when the event is
* not a recognizable video note (caller renders the generic card).
*/
export function parseVideoEvent(event) {
if (!event || typeof event !== "object") {
return null;
}
const base = { pubkey: event.pubkey, deleted: false, isPrivate: false };
// --- bitvid native (kind 30078, JSON content) ---
if (typeof event.content === "string" && event.content.trim().startsWith("{")) {
try {
const content = JSON.parse(event.content);
if (content && typeof content === "object") {
return {
...base,
title: content.title,
description: content.description,
thumbnail: content.thumbnail,
deleted: content.deleted === true,
isPrivate: content.isPrivate === true,
isNsfw: content.isNsfw === true || hasTag(event.tags, "content-warning"),
invalid: !content.title || (!content.url && !content.magnet),
};
}
} catch (err) {
// Not JSON after all — fall through to the NIP-71 reading.
}
}
// --- NIP-71 (metadata in tags, description in content) ---
if (NIP71_VIDEO_KINDS.includes(event.kind)) {
const title = firstTagValue(event.tags, "title");
const thumbnail =
imetaImageUrl(event.tags) ||
sanitizeAbsoluteUrl(firstTagValue(event.tags, "image")) ||
sanitizeAbsoluteUrl(firstTagValue(event.tags, "thumb"));
const description =
(typeof event.content === "string" && event.content.trim()) ||
firstTagValue(event.tags, "summary") ||
firstTagValue(event.tags, "alt");
return {
...base,
title,
description,
thumbnail,
// NIP-71 has no privacy flag; `content-warning` is the NSFW signal.
isNsfw: hasTag(event.tags, "content-warning"),
invalid: !title,
};
}
return null;
}
function metaTag(attribute, name, content) {
return ` <meta ${attribute}="${escapeHtmlAttribute(name)}" content="${escapeHtmlAttribute(content)}" />`;
}
/**
* Render the crawler-facing HTML document.
*
* @param {object} params
* @param {object|null} params.video Normalized video object, or null.
* @param {string} params.shareUrl Canonical `/?v=<nevent>` URL.
* @param {string} [params.embedUrl] `/embed.html?pointer=<nevent>` player URL.
* @param {object} params.fallback Site-wide defaults (title/description/image/url).
* @param {object} [params.gate] Result of isPubliclyShareable().
* @returns {string} A complete HTML document.
*/
export function renderOgHtml({
video = null,
shareUrl = "",
embedUrl = "",
fallback = {},
gate = null,
} = {}) {
const siteName = normalizeText(fallback.siteName || "bitvid", 60);
const fallbackTitle = normalizeText(
fallback.title || "bitvid - Decentralized Video Sharing",
MAX_TITLE_LENGTH
);
const fallbackDescription = normalizeText(
fallback.description || "Decentralized video sharing on Nostr.",
MAX_DESCRIPTION_LENGTH
);
const fallbackImage = sanitizeAbsoluteUrl(fallback.image);
const canonical = sanitizeAbsoluteUrl(shareUrl) || sanitizeAbsoluteUrl(fallback.url);
const decision = gate || isPubliclyShareable(video);
const useVideo = decision.allowed === true;
const title = useVideo
? normalizeText(video.title, MAX_TITLE_LENGTH) || fallbackTitle
: fallbackTitle;
const description = useVideo
? normalizeText(video.description, MAX_DESCRIPTION_LENGTH) ||
fallbackDescription
: fallbackDescription;
const image = useVideo
? sanitizeAbsoluteUrl(video.thumbnail) || fallbackImage
: fallbackImage;
const player = useVideo ? sanitizeAbsoluteUrl(embedUrl) : "";
const lines = [
"<!doctype html>",
'<html lang="en">',
" <head>",
' <meta charset="utf-8" />',
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
` <title>${escapeHtmlAttribute(title)}</title>`,
];
if (canonical) {
lines.push(` <link rel="canonical" href="${escapeHtmlAttribute(canonical)}" />`);
}
lines.push(metaTag("name", "description", description));
lines.push(metaTag("property", "og:site_name", siteName));
lines.push(metaTag("property", "og:type", useVideo ? "video.other" : "website"));
lines.push(metaTag("property", "og:title", title));
lines.push(metaTag("property", "og:description", description));
if (canonical) {
lines.push(metaTag("property", "og:url", canonical));
}
if (image) {
lines.push(metaTag("property", "og:image", image));
lines.push(metaTag("property", "og:image:alt", title));
}
if (player) {
// A player card is what makes a shared bitvid link behave like a YouTube
// link: the video plays inline in the timeline instead of showing a still.
lines.push(metaTag("property", "og:video:url", player));
lines.push(metaTag("property", "og:video:secure_url", player));
lines.push(metaTag("property", "og:video:type", "text/html"));
lines.push(metaTag("property", "og:video:width", String(DEFAULT_PLAYER_WIDTH)));
lines.push(metaTag("property", "og:video:height", String(DEFAULT_PLAYER_HEIGHT)));
lines.push(metaTag("name", "twitter:card", "player"));
lines.push(metaTag("name", "twitter:player", player));
lines.push(metaTag("name", "twitter:player:width", String(DEFAULT_PLAYER_WIDTH)));
lines.push(metaTag("name", "twitter:player:height", String(DEFAULT_PLAYER_HEIGHT)));
} else {
lines.push(metaTag("name", "twitter:card", image ? "summary_large_image" : "summary"));
}
lines.push(metaTag("name", "twitter:title", title));
lines.push(metaTag("name", "twitter:description", description));
if (image) {
lines.push(metaTag("name", "twitter:image", image));
}
lines.push(" </head>");
lines.push(" <body>");
// A human should never land here (only crawler user-agents are rewritten),
// but if one does, give them a real link rather than a blank page.
lines.push(
canonical
? ` <p><a href="${escapeHtmlAttribute(canonical)}">${escapeHtmlAttribute(title)}</a></p>`
: ` <p>${escapeHtmlAttribute(title)}</p>`
);
lines.push(" </body>");
lines.push("</html>");
return `${lines.join("\n")}\n`;
}
// Conservative crawler match. Being wrong in the "not a bot" direction is free
// (the visitor gets the normal SPA); being wrong the other way would serve a
// human this stub page, so the pattern stays narrow and explicit.
export const CRAWLER_USER_AGENT_PATTERN =
/(facebookexternalhit|twitterbot|slackbot|discordbot|telegrambot|whatsapp|linkedinbot|pinterest|redditbot|embedly|quora link preview|nostrbot|primal|iframely|opengraph|mastodon|bluesky|skypeuripreview|vkshare|googlebot|bingbot|applebot|yandexbot|duckduckbot|baiduspider|petalbot|semrushbot|ahrefsbot|developers\.google\.com\/\+\/web\/snippet)/i;
export function isCrawlerUserAgent(userAgent) {
return typeof userAgent === "string"
? CRAWLER_USER_AGENT_PATTERN.test(userAgent)
: false;
}
+273
View File
@@ -0,0 +1,273 @@
// Vercel serverless handler: renders per-video OpenGraph / Twitter-player tags
// for crawlers hitting `/?v=<nevent>`.
//
// Nothing is pre-generated and nothing is persisted. This is a stateless
// function invoked per crawler request; `dist/` is byte-identical with or
// without it. The only caching is the CDN edge cache via Cache-Control below.
//
// This must never become load-bearing for the app: real users are not routed
// here (see the user-agent rewrite in vercel.json), and a static/nsite deploy
// without any functions keeps working exactly as it does today, just with the
// site-wide card.
import { nip19 } from "nostr-tools";
import { SimplePool, useWebSocketImplementation } from "nostr-tools/pool";
import WebSocket from "ws";
import {
isCrawlerUserAgent,
isPubliclyShareable,
parseVideoEvent,
renderOgHtml,
} from "./_lib/ogRenderer.mjs";
// Imported, not duplicated: these modules are plain ESM with no DOM access, so
// the function reads the SAME admin-list identity and relay set the browser
// does. A hardcoded copy would silently drift the moment an operator retunes
// the instance, and the failure mode (previews gated forever, or worse, a stale
// whitelist) would be invisible.
import {
ADMIN_LIST_NAMESPACE,
ADMIN_SUPER_NPUB,
} from "../js/config.js";
import { DEFAULT_RELAY_URLS } from "../js/nostr/toolkit.js";
import { ADMIN_LIST_IDENTIFIERS } from "../js/nostrEventSchemas.js";
useWebSocketImplementation(WebSocket);
const RELAYS = Array.from(DEFAULT_RELAY_URLS);
// Admin moderation lists are addressable kind-30000 sets published by the
// instance's super admin (js/adminListStore.js).
const ADMIN_LIST_KIND = 30000;
// Measured: a COLD pool's first read across these relays can take ~3s (WSS
// handshake + REQ round trip on four connections). 1.5s and 2.5s both cut it
// off and served the generic card every time. Twitter allows ~5s and Facebook
// ~10s, so 4s buys a correct card on a cold container while still leaving
// headroom under Vercel's function limit. Warm containers answer in <600ms.
const RELAY_TIMEOUT_MS = 4000;
// Admin lists are identical for every request, so re-reading them on each crawl
// is pure waste (and it was starving the video read of its budget). Memoized in
// module scope: warm invocations skip the relay round trip entirely. This is a
// per-container, in-memory cache of PUBLIC data that dies with the container —
// nothing is persisted anywhere.
const ADMIN_LIST_TTL_MS = 5 * 60 * 1000;
let adminListCache = { expiresAt: 0, whitelist: null, blacklist: null };
const SITE_ORIGIN = process.env.BITVID_SITE_ORIGIN || "https://bitvid.network";
const FALLBACK = Object.freeze({
siteName: "bitvid",
title: "bitvid - Decentralized Video Sharing",
description:
"bitvid is a decentralized video sharing platform built on Nostr and WebTorrent.",
image: `${SITE_ORIGIN}/assets/jpg/bitvid.jpg`,
url: SITE_ORIGIN,
});
function withTimeout(promise, ms, fallbackValue) {
return Promise.race([
promise.catch(() => fallbackValue),
new Promise((resolve) => setTimeout(() => resolve(fallbackValue), ms)),
]);
}
/** Decode `?v=` (nevent/naddr/note/raw hex) into a relay filter. */
function pointerToFilter(pointer) {
const trimmed = typeof pointer === "string" ? pointer.trim() : "";
if (!trimmed) {
return null;
}
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return { ids: [trimmed.toLowerCase()] };
}
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === "nevent" && decoded.data?.id) {
return { ids: [decoded.data.id] };
}
if (decoded.type === "note" && typeof decoded.data === "string") {
return { ids: [decoded.data] };
}
if (decoded.type === "naddr" && decoded.data?.identifier) {
return {
kinds: [decoded.data.kind],
authors: [decoded.data.pubkey],
"#d": [decoded.data.identifier],
};
}
} catch (err) {
// Unparseable pointer -> generic card.
}
return null;
}
function extractPubkeySet(event) {
const out = new Set();
if (!event || !Array.isArray(event.tags)) {
return out;
}
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag[0] !== "p" || typeof tag[1] !== "string") {
continue;
}
const value = tag[1].trim().toLowerCase();
if (/^[0-9a-f]{64}$/.test(value)) {
out.add(value);
continue;
}
try {
const decoded = nip19.decode(value);
if (decoded.type === "npub" && typeof decoded.data === "string") {
out.add(decoded.data.toLowerCase());
}
} catch (err) {
// ignore malformed entries
}
}
return out;
}
export default async function handler(req, res) {
const host = req.headers?.host || new URL(SITE_ORIGIN).host;
const proto = req.headers?.["x-forwarded-proto"] || "https";
const requestUrl = new URL(req.url || "/", `${proto}://${host}`);
const pointer = requestUrl.searchParams.get("v") || "";
// Derive the origin from the REQUEST, not from a constant. Otherwise every
// non-production deployment (unstable, previews) advertises production's
// canonical URL and — worse — a twitter:player pointing at production's
// embed, so testing a preview link silently exercises the live site.
const origin = `${proto}://${host}`;
const shareUrl = pointer
? `${origin}/?v=${encodeURIComponent(pointer)}`
: origin;
const embedUrl = pointer
? `${origin}/embed.html?pointer=${encodeURIComponent(pointer)}`
: "";
const renderFallback = (reason) => {
// Short TTL: a video may be published, edited or moderated at any time, and
// we would rather re-ask the relays than pin a wrong card in someone's CDN.
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "public, s-maxage=300, stale-while-revalidate=600");
res.setHeader("X-Bitvid-OG", reason);
res.status(200).send(
renderOgHtml({
video: null,
shareUrl,
fallback: { ...FALLBACK, url: origin, image: `${origin}/assets/jpg/bitvid.jpg` },
})
);
};
// Defensive: the rewrite should only send crawlers here, but never serve a
// human this stub if the rule ever widens.
if (!isCrawlerUserAgent(req.headers?.["user-agent"])) {
res.setHeader("Cache-Control", "no-store");
res.redirect(302, shareUrl);
return;
}
const filter = pointerToFilter(pointer);
if (!filter) {
renderFallback("no-pointer");
return;
}
const pool = new SimplePool();
let superAdminHex = "";
try {
const decoded = nip19.decode(ADMIN_SUPER_NPUB);
superAdminHex = decoded.type === "npub" ? decoded.data : "";
} catch (err) {
superAdminHex = "";
}
try {
const listFilter = (identifier) => ({
kinds: [ADMIN_LIST_KIND],
"#d": [`${ADMIN_LIST_NAMESPACE}:${identifier}`],
...(superAdminHex ? { authors: [superAdminHex] } : {}),
});
// One shared deadline across all three reads.
const now = Date.now();
const adminListsFresh =
adminListCache.expiresAt > now && adminListCache.whitelist instanceof Set;
const [videoEvent, whitelistEvent, blacklistEvent] = await Promise.all([
withTimeout(pool.get(RELAYS, filter), RELAY_TIMEOUT_MS, null),
adminListsFresh
? Promise.resolve(null)
: withTimeout(
pool.get(RELAYS, listFilter(ADMIN_LIST_IDENTIFIERS.whitelist)),
RELAY_TIMEOUT_MS,
null
),
adminListsFresh
? Promise.resolve(null)
: withTimeout(
pool.get(RELAYS, listFilter(ADMIN_LIST_IDENTIFIERS.blacklist)),
RELAY_TIMEOUT_MS,
null
),
]);
let whitelist = adminListsFresh ? adminListCache.whitelist : null;
let blacklist = adminListsFresh ? adminListCache.blacklist : null;
if (!adminListsFresh) {
whitelist = extractPubkeySet(whitelistEvent);
blacklist = extractPubkeySet(blacklistEvent);
// Only cache a whitelist we actually got. Caching an empty one would pin
// "whitelist-unavailable" for the whole TTL after a single slow read.
if (whitelist.size > 0) {
adminListCache = {
expiresAt: now + ADMIN_LIST_TTL_MS,
whitelist,
blacklist,
};
}
}
const video = parseVideoEvent(videoEvent);
if (!video) {
renderFallback("event-not-found");
return;
}
const gate = isPubliclyShareable(video, {
whitelistEnabled: true,
whitelist,
blacklist,
});
if (!gate.allowed) {
renderFallback(`gated:${gate.reason}`);
return;
}
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "public, s-maxage=300, stale-while-revalidate=600");
res.setHeader("X-Bitvid-OG", "video");
res.status(200).send(
renderOgHtml({
video,
shareUrl,
embedUrl,
fallback: { ...FALLBACK, url: origin, image: `${origin}/assets/jpg/bitvid.jpg` },
gate,
})
);
} catch (err) {
renderFallback("error");
} finally {
try {
pool.close(RELAYS);
} catch (err) {
// ignore
}
}
}
+4
View File
@@ -39,6 +39,10 @@ Welcome to the bitvid documentation. This directory contains detailed architectu
## Active plans
- **[Performance & Efficiency Plan](./performance-efficiency-plan.md)**: A
measurement-first staged plan for reducing client CPU, memory, and network
work without degrading Nostr discovery, feed freshness, moderation, or
playback. Tracked as TODO #34.
- **[BitUnlock Permission-Layer TODO](../todo/TODO_2026-07-23_bitunlock-permission-layer.md)**: Ordered implementation checklist for the BitUnlock-backed media permission and subscription work.
For more details on contributing, please see [CONTRIBUTING.md](../CONTRIBUTING.md).
+113
View File
@@ -0,0 +1,113 @@
# Channel subscriber counts — feature scope
> Status: **Scoped; not implemented** · Created 2026-07-26
## Goal
Show a useful subscriber/follower count on public BitVid channel profiles without
misrepresenting an estimate as a global Nostr fact or weakening private
subscriptions.
## Constraint that determines the design
BitVid subscriptions are encrypted, private NIP-51 lists. Relays cannot answer
"how many people subscribe to this creator?" from those lists. Querying public
follow lists, where they exist, would be incomplete, expensive, sybil-prone,
and would encourage users to make private follows discoverable. Therefore this
feature must not derive a global count by scanning followers' events.
## Recommended product definition
Ship a clearly labelled **BitVid followers** count, not an unqualified global
"Subscribers" count. It should count only voluntary, public follow attestations
that BitVid can verify and should remain an estimate of the BitVid-visible
audience. Keep the existing private subscription feature private and do not use
it as a counting source.
Before implementation, make one explicit product decision:
1. **Public attestation count (recommended):** a viewer may opt in to publish a
public, signed follow attestation; BitVid counts valid unique authors for a
creator. This has a real verifiable numerator but is necessarily partial.
2. **Creator-reported count:** creator publishes a signed number. This is cheap,
but is self-reported and must be labelled as such.
3. **No public count:** retain private subscriptions only. This is the strongest
privacy option.
Do not silently fall back from option 1 to option 2.
## Proposed implementation slice (option 1)
### Data model and protocol
- Research existing Nostr follow/interest conventions first; reuse an
interoperable event if one supports a public creator follow without exposing
the private BitVid list.
- If no suitable convention exists, write a small BitVid event proposal before
coding: event kind, tags, replaceability/deletion semantics, creator target,
timestamp rules, relay selection, and migration/version tag.
- Count at most one current attestation per follower/creator relationship.
- Ignore malformed events, self-follows, deleted/replaced events, and events
outside the selected protocol version.
- Treat counts as eventually consistent and relay-scoped. Never claim a global
Nostr total.
### Read path and caching
- Add a dedicated count service/store; do not add follower scans to
`channelProfile.js` rendering or the normal feed bootstrap.
- Fetch only when a channel profile is opened, with a bounded relay set,
cancellation on profile navigation, in-flight deduplication, and a short
cache TTL.
- Render a compact skeleton or omit the count while loading. A failed count
lookup must never block profile metadata, video loading, zap controls, or
subscriptions.
- Expose source/coverage only in a low-noise detail surface if needed (for
example, "BitVid-visible followers"), not as a misleading exact total.
### Channel-profile UI
- Place the count in the creator summary/stat row near videos/zaps, not in the
profile modal.
- Use the chosen product label consistently: `BitVid followers` for public
attestations; `Creator-reported subscribers` for self-reported data.
- Provide an accessible label and compact mobile layout; large counts use
locale-aware formatting.
- The viewer's private Subscribe/Unsubscribe control remains unchanged. If an
opt-in public follow is added, it is a separate, explicit privacy choice.
### Abuse and privacy safeguards
- Do not expose who follows whom by default beyond the public attestations the
viewer chose to publish.
- Do not rank or moderate creators solely by this count.
- Rate-limit/bound relay queries and deduplicate by author; document that sybil
resistance is not provided by a raw public count.
- Preserve viewer blocks/mutes and creator visibility rules in any optional
follower preview; the count itself must not leak a viewer's private list.
## Tests and acceptance criteria
- Unit tests: unique-author counting, replacement/deletion, malformed events,
self-follow exclusion, cache TTL, and formatted display values.
- Service tests: bounded/cancelled relay queries, timeout/failure returns an
unavailable state without clearing a good cached count.
- UI tests: loading, available, unavailable, and mobile wrapping states.
- Manual QA: opening a channel profile has no visible impact on Home feed or
private subscription list loading; toggling a private subscription publishes
no public attestation unless the viewer explicitly opts in.
## Non-goals for the first release
- A global Nostr subscriber count.
- Inferring counts from private encrypted lists.
- Follower directories, recommendations, notifications, or monetized tiers.
- Using counts as a trust, moderation, or discovery ranking signal.
## Implementation order
1. Approve the product definition and public/private wording.
2. Complete protocol/convention research and record the selected event shape.
3. Build the bounded count store with tests and fixtures.
4. Add channel-profile presentation and responsive UI tests.
5. Run privacy/performance QA before enabling public-attestation publishing.
+99 -5
View File
@@ -1,6 +1,44 @@
# BitVid Performance & Efficiency Plan
Status: **planned — deeper system audit complete; no performance changes implemented**
Status: **Stage 2 shipped; Stage 3 in progress — observer teardown shipped, probe cancellation next**
## Progress snapshot — 2026-07-26
The plan is being delivered as independent, test-gated slices on `unstable`.
### Shipped
- **Stage 0 foundation:** the opt-in local performance harness is available;
it remains disabled unless the loopback-only storage flag is set.
- **Stage 1 complete:** localhost relay-request monitoring is explicit opt-in.
- **Stage 2 complete:** `ExploreDataService` is demand-created for For You and
Explore, pauses outside active recommendation views, coalesces concurrent
history/IDF refreshes, and no longer blocks cached For You rendering.
- **Stage 3 first slice:** card, URL-health, and grid-health observers are
disposed when feed markup is replaced or unmounted.
- **Stage 7 first slice:** recommendation refresh bursts are coalesced into one
active pass plus one follow-up; Most Zapped ordering has regression coverage.
### Remaining
- Capture and retain the complete Stage 0 baseline scenarios, including cache
size, worker lifetime, probe counts, and browser CPU/RSS.
- Finish Stage 3 with owner-aware queue pruning and abort signals for URL and
WebTorrent probes, then verify hidden-card reveal and fallback behavior.
- Finish Stage 7 with bounded, category-aware event-cache eviction and same-feed
refresh coalescing without swallowing explicit refreshes.
- Implement Stage 5's service-worker update cadence and image-cache freshness
policy after measuring warm-feed revalidation.
- Profile before changing Stage 4 (modal visuals) or Stage 6 (bundle/lazy
loading); these remain unstarted and measurement-dependent.
### Current release evidence
- `5f439d68`: lazy recommendation indexes.
- `687f8c43`: lifecycle disposal, refresh coalescing, and Most Zapped ordering.
- `bb6614e3`: cache-first For You navigation rendering.
- Targeted regression suites and production builds pass for each shipped slice;
the current remote tip is `origin/unstable` at `bb6614e3`.
## Goal
@@ -86,12 +124,26 @@ intact.
7. **Measure both wall time and retained state.** A faster task that retains a
large worker snapshot, observer target, cache record, or probe client is not
an efficiency win.
8. **Preserve discovery boundaries.** A lifecycle or batching optimization must
use the same scoped relay/source selection for historical fetch and live
subscription. Never make a feature appear faster by querying a smaller,
unrelated source set and silently omitting valid user data.
Every affected feature needs a regression fixture containing data available
only through its scoped historical source and data arriving live afterward.
## Measurement harness (Stage 0)
## Measurement harness (Stage 0) — foundation shipped; baseline capture remains
Create a local-only, opt-in performance harness. It must be disabled by
default and absent from production behavior.
Implemented baseline surface: on `localhost`, `127.0.0.1`, or `::1`, set
`localStorage.__bitvidPerformanceHarness__ = "1"` and reload. The console API
`window.__bitvidPerformance.snapshot()` reports bounded long-task, resource,
navigation, heap (when supported), mark, and manually supplied gauge data.
`mark`, `setGauge`, and `increment` are available for later stage-specific
instrumentation. The harness does not initialize on a public host, even if the
storage key is set.
### Measurements
- Long tasks via `PerformanceObserver`.
@@ -115,6 +167,8 @@ default and absent from production behavior.
5. Open hosted playback, then close it.
6. Open torrent-backed playback, then close it.
7. Background the tab for two minutes and restore it.
8. Load an inbox with both legacy NIP-04 and historical NIP-17 conversations,
then receive a live NIP-17 message; verify all conversations are present.
### Stage gate
@@ -122,13 +176,14 @@ Record baseline results before any optimization. The harness itself must have a
test proving it does not initialize unless its explicit local debug flag is
set.
## Stage 1 — Make localhost relay monitoring explicit
## Stage 1 — Make localhost relay monitoring explicit — complete
### Change
Change `js/devReqMonitor.js` so it only installs when
`localStorage.__bitvidReqMonitor__ === "1"` (or an equivalent explicit local
debug flag). Do not auto-enable merely because the host is localhost.
`localStorage.__bitvidReqMonitor__ === "1"` on a loopback host. Do not
auto-enable merely because the host is localhost, and never permit the monitor
on a public host.
### Why
@@ -143,6 +198,12 @@ local performance testing.
- No production host can enable it without the explicit local flag.
- Compare idle local CPU/long-task baseline before and after.
Implemented with `isRequestMonitorEnabled()` / `installRequestMonitor()` and
regression coverage for disabled localhost, enabled loopback, and public-host
rejection. To use it during a targeted local relay investigation, set
`localStorage.__bitvidReqMonitor__ = "1"` and reload; remove the key or set it
to any other value for normal profiling.
### Rollback
One isolated commit; restoring the previous activation condition restores the
@@ -150,6 +211,15 @@ old diagnostic behavior.
## Stage 2 — Lazy recommendation indexes
Implemented: `ExploreDataService` is no longer created during
ordinary bootstrap. Home/Profile sessions therefore create no recommendation
worker or recommendation intervals. Opening For You or Explore creates and
activates the service; events received while inactive mark it dirty and are
included in the activation refresh. Concurrent history/IDF signals share one
running rebuild and retain one forced follow-up pass. Focused lifecycle,
feed-coordinator, production-build, and browser navigation checks pass; manual
For You/Explore validation confirmed normal recommendations and responsive Home.
### Change
Do not initialize `ExploreDataService` globally in application bootstrap.
@@ -193,6 +263,27 @@ eager initialization if feed freshness regresses.
## Stage 3 — Bound source-health work to visible cards
**Progress:** first lifecycle-disposal slice shipped in `687f8c43`; queue
ownership, cancellation, and abortable active probes remain.
### Audit findings (2026-07-26)
- `createCardObserver` retains an `IntersectionObserver` per grid container but
has no disposal API. `VideoListView.render()` replaces card DOM without
disconnecting the old observer, so detached cards can stay observed.
- `gridHealth` has a globally bounded, priority-ordered torrent queue, but its
queued jobs have no owner/cancellation handle. A discarded card can therefore
still start a real tracker/WebRTC probe.
- URL health uses an independent observer and video-probe wait queue with the
same missing ownership boundary. Its fetch timeout aborts only on timeout,
not when the card/grid disappears.
Implementation order: add disposable observer registrations and call them from
grid replacement/unmount; associate queued jobs with card owners and prune jobs
when their last owner disappears; finally pass an abort signal into active URL
and torrent probes. Cache/in-flight deduplication stays shared so one visible
card never cancels work still needed by another visible card.
### Change
Keep the existing viewport/preload observer and current two-at-a-time torrent
@@ -295,6 +386,9 @@ or basic playback path in a way that creates visible delay.
## Stage 7 — Cache budget and feed-refresh coalescing
**Progress:** recommendation-index refresh coalescing shipped in `5f439d68`;
the event-cache budget and full same-feed refresh coordinator remain.
### Change
After Stage 0 records real cache growth, add explicit event-cache record and
+125
View File
@@ -94,8 +94,133 @@ sequenceDiagram
end
```
## HLS (.m3u8) sources
Hosted sources whose path ends in `.m3u8` are HLS playlists and cannot be
assigned to `videoEl.src` on every browser. `js/services/hlsPlayback.js` owns
the decision and the hls.js lifecycle.
**Engine choice.** hls.js (MSE) wins whenever `MediaSource` can back it; native
playback is used only when MSE is unavailable. Do NOT branch on `canPlayType`
alone — measured behavior differs sharply:
| Browser | `canPlayType("application/vnd.apple.mpegurl")` | bare `<video src=…m3u8>` |
| --- | --- | --- |
| Firefox 146 | `""` | `MEDIA_ERR_SRC_NOT_SUPPORTED` (code 4) |
| Chrome 149 | `"maybe"` | plays natively |
Before this existed, Firefox reported `No playable source found.` for every HLS
note (and the URL-health badge showed `❌ CDN`), while Chrome happened to work.
**Wiring.**
- `js/services/playbackService.js` — `attemptHostedPlayback` routes HLS
candidates through `attachHlsSource()`; `resetVideoElement()` calls
`detachHls()` before clearing `src`.
- `js/app/modalCoordinator.js` — `teardownVideoElement` calls `detachHls()`
first; otherwise the transmuxer worker and segment fetches outlive the modal.
- `js/ui/urlHealthController.js` — probes HLS by fetching the playlist and
checking for `#EXTM3U` instead of using a bare `<video>` (which cannot load it
and, on Chrome, emits no events at all — burning the whole probe timeout).
- `HLS_PLAYBACK_START_TIMEOUT` (`js/constants.js`) widens the 3s start window,
which fires mid-startup for HLS: the bundle, master playlist, variant
playlist, init segment and first media segment are serial round trips.
**Vendoring.** `npm run build:hls` regenerates `vendor/hls.bundle.min.js` from
the pinned devDependency. It is the FULL hls.js build, not `hls.js/light` —
light drops the alternate-audio stream controller, and real ladders (e.g.
nostube/slidestr) demux audio into its own `EXT-X-MEDIA` rendition group. The
bundle is lazy-imported only when an HLS URL is encountered, so ordinary
MP4/WebTorrent playback never pays for it.
**Rollback.** Stop calling `attachHlsSource()` from `playbackService.js` /
`playbackCoordinator.js` and the previous `videoEl.src = url` behavior returns;
revert the `urlHealthController.js` branch to restore the old element probe.
Nothing else imports `hlsPlayback.js`, and the vendored bundle is inert once
unreferenced.
## Implementation notes
- The playback session is responsible for cleanup and emits events (`status`, `sourcechange`, `error`, `finished`) that `bitvidApp` listens to for UI updates.
- `bitvidApp.playVideoWithFallback` keeps the current video card and modal state in sync with the normalized magnet values so copy actions and stats reflect the actual playback payload.
## Link previews (NIP-92 imeta + per-video OpenGraph)
Two independent surfaces make a shared bitvid video render richly.
**1. Inside nostr clients — `js/nostrEventSchemas.js`.**
`buildShareEvent()` appends a NIP-92 `imeta` entry describing the thumbnail URL
that the note body ends with. Many clients only inline such a URL when an imeta
entry backs it; without it the viewer sees a bare link. The media type is
inferred from the extension (`inferImageMimeTypeFromUrl`, `js/utils/mime.js`)
and OMITTED when unknown — a wrong `m` is worse than a missing one, because
clients use it to decide whether to render inline at all. Extensionless
Blossom-style URLs therefore ship `url` + `alt` only.
**2. Everywhere else — `api/og.js` + `api/_lib/ogRenderer.mjs`.**
`index.html` carries one set of site-wide OG tags, so every `/?v=<nevent>` link
previewed as the same generic card. Crawlers don't run JS, so this is rendered
server-side on demand.
- **Nothing is pre-generated or stored.** One stateless function serves all
videos; the video is a query param, not a path. `dist/` is byte-identical
with or without it. The only caching is the CDN edge (`s-maxage=300`) plus a
per-container in-memory memo of the admin lists.
- **Never load-bearing.** Only crawler user-agents are rewritten to it
(`vercel.json`), humans get the untouched SPA, and a static/nsite deploy with
no functions keeps working — it just serves the site-wide card. Rollback is
deleting the `rewrites` block.
- **Player card, not just an image.** It emits `twitter:player` / `og:video:url`
pointing at `embed.html?pointer=<nevent>`, so a shared link plays inline on
Twitter/Discord/Telegram. `frame-ancestors *` (`_headers`) already permits it.
- **Fails closed.** A crawler never authenticates, so anything emitted is public
forever in third-party caches. `isPubliclyShareable()` requires an
affirmatively public video: `deleted`, `isPrivate`, `isNsfw`/`content-warning`,
blacklisted authors, and — while whitelist mode is on — an unavailable or
empty whitelist all fall back to the generic card.
- **Both event shapes.** `parseVideoEvent()` reads bitvid's kind-30078 v3 JSON
AND the NIP-71 tag shape (kinds 21/22/34235/34236) that nostube/slidestr
publish, where the title lives in a `title` tag and the thumbnail in an
`imeta image` entry.
- **Timeouts.** A cold pool's first relay read measured ~3s, so the budget is
4s (Twitter allows ~5s); warm containers answer in <600ms because the admin
lists are memoized for 5 minutes.
Config is imported from `js/config.js`, `js/nostr/toolkit.js` and
`js/nostrEventSchemas.js` rather than duplicated, and `tests/og-renderer.test.mjs`
fails if that regresses to hardcoded literals.
### Quoting the NIP-71 mirror
When a video has a NIP-71 mirror, the share note embeds `nostr:naddr1…` for it.
Nostr clients render that as a **native video quote card** — the closest thing
to a rich preview that does not depend on any client fetching OpenGraph tags
(most never do; the OG endpoint above mainly serves Twitter/Discord/Telegram).
The mirror is addressable (kind 34235/34236, author = video pubkey,
`d` = `videoRootId`), so `js/nostr/mirrorPointer.js` derives the coordinate with
no relay lookup, and the pointer survives the author editing the video — a plain
`nevent` would rot on the next replace.
Rules that keep this from looking worse than the plain link:
- **Verify before pointing.** `ShareNostrController.resolveMirrorPointer()`
confirms the mirror exists via `nip71MirrorService.findMirror()` before
embedding anything. A pointer to a missing event renders as a broken/empty
quote box. Every failure path — service disabled, lookup throws, lookup times
out (2.5s), no `videoRootId` — falls back to the thumbnail-URL note.
- **Use the observed kind.** `resolveMirrorKind()` prefers the kinds actually
seen on relays over the portrait heuristic. Only when nothing was observed does
it fall back to the SAME rule the mirror builder uses (`nip71Mirror.js`:
`hasDims && height > width`), so a derived pointer matches what would be
published rather than guessing independently.
- **Never emit both images.** While quoting, the raw thumbnail URL is dropped
from the body and the `imeta` tag is skipped — the quote card already shows the
thumbnail, and keeping both rendered the same image twice.
- An `a` tag (`kind:pubkey:d`) links the note to the quoted coordinate for
quote-aware clients and indexers.
Known limitation: a client rendering the quote card still needs to play the
video, and mirrors whose `imeta url` is an `.m3u8` hit the same HLS wall bitvid
did — the thumbnail and title render, playback depends on the client.
+30
View File
@@ -176,6 +176,22 @@ export function createAuthSessionCoordinator(deps) {
return {
async handleAuthLogin(detail = {}) {
const requestedPubkey =
this.normalizeHexPubkey?.(detail?.pubkey || this.pubkey) ||
detail?.pubkey ||
this.pubkey ||
"";
const inFlightLogin = this._authLoginSync;
if (
requestedPubkey &&
inFlightLogin?.pubkey === requestedPubkey &&
inFlightLogin?.promise
) {
userLogger.info("[auth-login-duplicate-ignored]", {
pubkey: requestedPubkey,
});
return inFlightLogin.promise;
}
const authLoginStart = now();
userLogger.info("[auth-login-start]", {
pubkey: detail?.pubkey || this.pubkey || null,
@@ -721,6 +737,20 @@ export function createAuthSessionCoordinator(deps) {
return listSyncDetail;
});
// A provider/widget can repeat its success event while the first login is
// still hydrating lists. The identity is already active, so replaying the
// full lifecycle only doubles relay work and grid refreshes. Join the
// original lifecycle until its required list sync settles instead.
const authLoginSync = Promise.resolve(listStatePromise).finally(() => {
if (this._authLoginSync?.promise === authLoginSync) {
this._authLoginSync = null;
}
});
this._authLoginSync = {
pubkey: activePubkey || requestedPubkey,
promise: authLoginSync,
};
// DMs are intentionally NOT eagerly loaded at login. A cold DM load
// (limit: 50) is a burst of nip-07 decrypts that competes with the
// feed-driving lists (blocks/subscriptions/hashtags) for the SAME
+38
View File
@@ -1014,6 +1014,27 @@ export function createFeedCoordinator(deps) {
devLogger.log(`Starting loadFeedVideos(${feedType})... (forceFetch =`, forceFetch, ")");
this.setFeedTelemetryContext(feedType);
const recommendationFeed =
feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.EXPLORE;
let exploreActivation = null;
if (recommendationFeed && typeof this.ensureExploreDataService === "function") {
// Recommendation indexes are an enhancement, not a prerequisite for
// showing cached videos. Activating the worker here used to leave a
// returning For You view stuck on its static loading markup whenever
// worker startup was slow or unavailable.
exploreActivation = Promise.resolve(this.ensureExploreDataService()).catch(
(error) => {
devLogger.warn(
`[Application] Recommendation data activation failed for ${feedType}:`,
error,
);
return null;
},
);
} else if (this.exploreDataService?.setActive) {
this.exploreDataService.setActive(false);
}
let includeTags = false;
let loadingMessage = "Fetching videos\u2026";
let refreshMethod = null;
@@ -1109,6 +1130,23 @@ export function createFeedCoordinator(deps) {
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
// Re-score once the optional recommendation indexes are ready, but only
// if the user is still looking at this feed. The first render above is
// deliberately cache-first and must not wait for worker startup.
if (exploreActivation) {
void exploreActivation.then(() => {
if (!this.isFeedActive(feedType)) {
return;
}
return this.refreshFeed(feedType, { reason: "recommendation-data-ready" });
}).catch((error) => {
devLogger.warn(
`[Application] Failed to refresh ${feedType} after recommendation data activation:`,
error,
);
});
}
},
/**
+9 -2
View File
@@ -3,11 +3,13 @@
/**
* Modal open/close lifecycle and controller routing.
*
* All module-level dependencies are injected from the Application
* composition root rather than imported at module scope.
* Stateful collaborators (clients, services, app state) are injected from the
* Application composition root rather than imported at module scope. Pure,
* stateless helper modules are imported directly, as in the other coordinators.
*
* Methods use `this` which is bound to the Application instance.
*/
import { detachHls } from "../services/hlsPlayback.js";
/**
* @param {object} deps - Injected dependencies.
@@ -562,6 +564,11 @@ export function createModalCoordinator(deps) {
`[teardownVideoElement] Resetting video (replaceNode=${replaceNode}) readyState=${videoElement.readyState} networkState=${videoElement.networkState} src=${describeSource()}`
);
// Destroy any hls.js instance FIRST. It owns a MediaSource, a transmuxing
// worker and in-flight segment fetches; clearing `src` without destroying
// it leaves those running (and downloading) after the modal is closed.
safe(() => detachHls(videoElement));
safe(() => videoElement.pause());
safe(() => {
+78 -5
View File
@@ -3,11 +3,17 @@
/**
* URL-first + magnet fallback playback pipeline.
*
* All module-level dependencies are injected from the Application
* composition root rather than imported at module scope.
* Stateful collaborators (clients, services, app state) are injected from the
* Application composition root rather than imported at module scope. Pure,
* stateless helper modules are imported directly, as in the other coordinators.
*
* Methods use `this` which is bound to the Application instance.
*/
import {
attachHlsSource,
detachHls,
isHlsUrl,
} from "../services/hlsPlayback.js";
/**
* Normalize a deep-link / caller "force this playback source" value to the
@@ -197,7 +203,41 @@ export function createPlaybackCoordinator(deps) {
if (!base) {
return "";
}
return `${base}?v=${encodeURIComponent(nevent)}`;
// getShareUrlBase() strips the trailing slash, so add the path separator
// back. `https://bitvid.network?v=...` is legal and browsers normalize
// it, but it reads as a typo in a shared note and some link parsers
// mis-detect where the URL ends.
return `${base}/?v=${encodeURIComponent(nevent)}`;
},
/**
* Relay hints to embed in a shared pointer.
*
* Without these an `nevent` is just a bare id: a viewer whose relay set
* doesn't happen to carry the event cannot resolve it, and bitvid falls
* back to the home feed — the "shared link just opens bitvid.network"
* report. Capped to a few because the hints inflate every shared URL.
*/
getSharePointerRelays() {
const candidates = Array.isArray(nostrClient?.writeRelays)
? nostrClient.writeRelays
: Array.isArray(nostrClient?.relays)
? nostrClient.relays
: [];
const seen = new Set();
const out = [];
for (const entry of candidates) {
const url = typeof entry === "string" ? entry.trim() : "";
if (!url || !/^wss?:\/\//i.test(url) || seen.has(url)) {
continue;
}
seen.add(url);
out.push(url);
if (out.length >= 3) {
break;
}
}
return out;
},
buildShareUrlFromEventId(eventId) {
@@ -206,7 +246,24 @@ export function createPlaybackCoordinator(deps) {
}
try {
const nevent = window.NostrTools.nip19.neventEncode({ id: eventId });
const pointer = { id: eventId };
// Author + relay hints make the pointer resolvable by someone who does
// not already follow this creator. The author also lets clients look
// the event up by author+kind if the id itself has aged out.
const known = this.videosMap?.get?.(eventId);
const author =
typeof known?.pubkey === "string" ? known.pubkey.trim() : "";
if (/^[0-9a-f]{64}$/i.test(author)) {
pointer.author = author.toLowerCase();
}
const relays = this.getSharePointerRelays();
if (relays.length) {
pointer.relays = relays;
}
const nevent = window.NostrTools.nip19.neventEncode(pointer);
return this.buildShareUrlFromNevent(nevent);
} catch (err) {
devLogger.error("Error generating nevent for share URL:", err);
@@ -355,7 +412,23 @@ export function createPlaybackCoordinator(deps) {
return false;
}
target.src = sanitizedUrl;
// HLS playlists cannot be assigned to `src` outside Safari; route them
// through hls.js/MSE (see js/services/hlsPlayback.js).
if (isHlsUrl(sanitizedUrl)) {
const handle = await attachHlsSource(target, sanitizedUrl, {
log: (...args) => devLogger.log(...args),
});
if (!handle) {
devLogger.warn(
"[playHttp] HLS source could not be attached:",
sanitizedUrl
);
return false;
}
} else {
detachHls(target);
target.src = sanitizedUrl;
}
try {
await target.play();
+11
View File
@@ -189,6 +189,17 @@ export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD =
export const DEFAULT_TRUST_SEED_NPUBS = SANITIZED_DEFAULT_TRUST_SEED_NPUBS;
export const PLAYBACK_START_TIMEOUT = SANITIZED_DEFAULT_PLAYBACK_START_TIMEOUT;
// HLS needs a bigger start window than a progressive MP4. Before the first
// frame can render, hls.js must lazy-load its (vendored) bundle, fetch the
// master playlist, fetch the chosen variant playlist, then fetch the init
// segment and the first media segment — four or more serial round trips versus
// the single ranged GET a plain MP4 needs. The 3s default fires mid-startup and
// reports a perfectly healthy stream as "No playable source found."
export const HLS_PLAYBACK_START_TIMEOUT = Math.max(
SANITIZED_DEFAULT_PLAYBACK_START_TIMEOUT,
15000
);
const DEFAULT_FLAGS = Object.freeze({
URL_FIRST_ENABLED: DEFAULT_PLAYBACK_SOURCE !== "torrent", // try URL before magnet in the player
FEATURE_WATCH_HISTORY_V2: true,
+79 -44
View File
@@ -1,69 +1,104 @@
// Dev-only relay REQ-rate monitor.
// Local-only relay REQ-rate monitor.
//
// Patches WebSocket.send to count outgoing Nostr REQ frames and, every 2s,
// console.warns when the rate exceeds a threshold — naming the storming filter
// kinds. This runs inside the real browser session against real relays/lists,
// which the headless harness cannot fully reproduce. It is purely observational
// and only activates on localhost (or when `localStorage.__bitvidReqMonitor__`
// is set), so production is unaffected.
//
// Remove this import from index.js once the login-perf work is done.
// It patches WebSocket.send for a real-session relay-storm investigation, so
// normal localhost development must opt in explicitly. Public deployments can
// never enable it, even if a storage key is present.
(function installReqMonitor() {
if (typeof window === "undefined" || typeof WebSocket === "undefined") return;
export const REQUEST_MONITOR_STORAGE_KEY = "__bitvidReqMonitor__";
const enabled =
location.hostname === "localhost" ||
location.hostname === "127.0.0.1" ||
(() => {
try {
return localStorage.getItem("__bitvidReqMonitor__") === "1";
} catch {
return false;
}
})();
if (!enabled || window.__bitvidReqMonitorInstalled) return;
window.__bitvidReqMonitorInstalled = true;
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
const THRESHOLD = 15;
const WINDOW_MS = 2000;
function readStorage(storage) {
try {
return storage?.getItem?.(REQUEST_MONITOR_STORAGE_KEY) ?? null;
} catch (_) {
return null;
}
}
export function isRequestMonitorEnabled({ locationRef, storage } = {}) {
const hostname = typeof locationRef?.hostname === "string"
? locationRef.hostname.toLowerCase()
: "";
return LOCAL_HOSTS.has(hostname) && readStorage(storage) === "1";
}
export function installRequestMonitor({
scope = typeof window !== "undefined" ? window : null,
locationRef = scope?.location,
storage = scope?.localStorage,
WebSocketClass = scope?.WebSocket,
setIntervalFn = scope?.setInterval?.bind(scope),
clearIntervalFn = scope?.clearInterval?.bind(scope),
logger = scope?.console || console,
} = {}) {
if (
!scope ||
!WebSocketClass?.prototype ||
typeof WebSocketClass.prototype.send !== "function" ||
typeof setIntervalFn !== "function" ||
!isRequestMonitorEnabled({ locationRef, storage })
) {
return null;
}
if (scope.__bitvidReqMonitorInstalled) {
return scope.__bitvidReqMonitorInstalled;
}
const THRESHOLD = 15; // REQ/s above this is a storm worth reporting
const WINDOW_MS = 2000;
let windowReqs = 0;
const byKind = new Map();
const originalSend = WebSocketClass.prototype.send;
const origSend = WebSocket.prototype.send;
WebSocket.prototype.send = function (data) {
WebSocketClass.prototype.send = function monitoredSend(data) {
try {
if (typeof data === "string" && data.startsWith('["REQ"')) {
windowReqs += 1;
const msg = JSON.parse(data);
for (const f of msg.slice(2)) {
const k = Array.isArray(f?.kinds) ? `kind ${f.kinds.join("/")}` : "kind ?";
byKind.set(k, (byKind.get(k) || 0) + 1);
const message = JSON.parse(data);
for (const filter of message.slice(2)) {
const kind = Array.isArray(filter?.kinds)
? `kind ${filter.kinds.join("/")}`
: "kind ?";
byKind.set(kind, (byKind.get(kind) || 0) + 1);
}
}
} catch {
/* ignore */
} catch (_) {
// Diagnostics must never affect a WebSocket send.
}
return origSend.apply(this, arguments);
return originalSend.apply(this, arguments);
};
setInterval(() => {
const perSec = (windowReqs / WINDOW_MS) * 1000;
if (perSec >= THRESHOLD) {
const intervalId = setIntervalFn(() => {
const perSecond = (windowReqs / WINDOW_MS) * 1000;
if (perSecond >= THRESHOLD) {
const top = [...byKind.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(([k, n]) => `${k}=${n}`)
.map(([kind, count]) => `${kind}=${count}`)
.join(", ");
// eslint-disable-next-line no-console
console.warn(
`[req-monitor] RELAY STORM ${perSec.toFixed(0)} REQ/s over ${WINDOW_MS / 1000}s → ${top}`,
logger?.warn?.(
`[req-monitor] RELAY STORM ${perSecond.toFixed(0)} REQ/s over ${WINDOW_MS / 1000}s → ${top}`,
);
}
windowReqs = 0;
byKind.clear();
}, WINDOW_MS);
// eslint-disable-next-line no-console
console.info("[req-monitor] active — will warn if relay REQ rate spikes.");
})();
const monitor = {
stop() {
WebSocketClass.prototype.send = originalSend;
if (typeof clearIntervalFn === "function") {
clearIntervalFn(intervalId);
}
if (scope.__bitvidReqMonitorInstalled === monitor) {
delete scope.__bitvidReqMonitorInstalled;
}
},
};
scope.__bitvidReqMonitorInstalled = monitor;
logger?.info?.("[req-monitor] active — will warn if relay REQ rate spikes.");
return monitor;
}
installRequestMonitor();
+29 -1
View File
@@ -26,6 +26,7 @@ export function createCardObserver(options = {}) {
prepareEntries,
onCardVisible,
onCardRegister,
onCardUnregister,
createState,
} = options;
@@ -37,7 +38,9 @@ export function createCardObserver(options = {}) {
return state;
}
const observedCards = new WeakSet();
// A Set is intentional here: disposal must enumerate every card so the
// browser observer cannot retain detached feed nodes after a rerender.
const observedCards = new Set();
const customState = normalizeState(
typeof createState === "function" ? createState(container) : {}
);
@@ -201,9 +204,34 @@ export function createCardObserver(options = {}) {
return state ? state.customState : null;
}
function disconnect(container) {
const state = containerState.get(container);
if (!state) {
return;
}
for (const card of state.observedCards) {
if (typeof onCardUnregister === "function") {
try {
onCardUnregister({
card,
state: state.customState,
context: { container: state.container, observer: state.observer },
});
} catch (err) {
userLogger.warn("[cardObserver] onCardUnregister failed", err);
}
}
}
state.observedCards.clear();
state.observer.disconnect();
containerState.delete(container);
}
return {
observe,
refresh,
getState,
disconnect,
};
}
+11 -5
View File
@@ -10,7 +10,7 @@ import { markAsNormalized } from "./utils.js";
// re-runs the feed on the metric cache's debounced change signal).
// - Trending (#27): runtime.getViewCount (viewCounter cache)
// - Most Zapped (#47): runtime.getZapTotal (zapTotals cache)
function createCountRankedSorter(resolveMetric) {
function createCountRankedSorter(resolveMetric, { spreadByAuthor = true } = {}) {
return function countRankedSorter(items = [], context = {}) {
if (!Array.isArray(items)) {
return [];
@@ -54,9 +54,12 @@ function createCountRankedSorter(resolveMetric) {
const live = items.filter((entry) => !isMuted(entry));
const muted = items.filter((entry) => isMuted(entry));
// Rank by the metric, then spread authors so the tab isn't walls of one
// creator. The top-ranked item is still chosen first, so it stays topmost.
return [...spreadAuthors(live.sort(compare)), ...muted.sort(compare)];
const rankedLive = live.sort(compare);
// Trending benefits from a little creator diversity. Most Zapped is a
// literal leaderboard, so it must retain strict total ordering instead of
// moving a larger zap total below a smaller one to avoid repeat authors.
const orderedLive = spreadByAuthor ? spreadAuthors(rankedLive) : rankedLive;
return [...orderedLive, ...muted.sort(compare)];
};
}
@@ -66,7 +69,10 @@ export function createTrendingSorter() {
// "Most Zapped" (#47): same ranking mechanics over sats totals.
export function createMostZappedSorter() {
return createCountRankedSorter((context) => context?.runtime?.getZapTotal);
return createCountRankedSorter(
(context) => context?.runtime?.getZapTotal,
{ spreadByAuthor: false },
);
}
// Round-robin interleave by author: one video per creator per pass, each pass in
+2 -1
View File
@@ -573,10 +573,11 @@ const gridCardObserver = createCardObserver({
export function attachHealthBadges(container, options = {}) {
if (!(container instanceof HTMLElement)) {
return;
return () => {};
}
if (options && typeof options.onUpdate === "function") {
badgeUpdateListeners.add(options.onUpdate);
}
gridCardObserver.observe(container);
return () => gridCardObserver.disconnect(container);
}
+5
View File
@@ -3,6 +3,7 @@
import { validateInstanceConfig } from "../config/validate-config.js";
import "./bufferPolyfill.js";
import "./devReqMonitor.js"; // dev-only relay REQ-rate monitor (localhost only)
import { initPerformanceHarness } from "./performanceHarness.js";
import {
getApplication,
setApplication,
@@ -59,6 +60,10 @@ import { installTestHarness } from "./testHarness.js";
validateInstanceConfig();
// Local-only, explicit opt-in measurement. This is a no-op in production and
// does not patch application APIs or change scheduling.
initPerformanceHarness();
// Install test harness early so relay overrides apply before connections.
// Only activates when ?__test__=1 or localStorage __bitvidTestMode__ is set.
installTestHarness();
+117
View File
@@ -0,0 +1,117 @@
// Addressable pointers to a video's NIP-71 mirror.
//
// The mirror (js/nostr/nip71Mirror.js) is an ADDRESSABLE event: kind
// 34235/34236, author = the video's pubkey, `d` = videoRootId. That makes its
// coordinate fully derivable from a video object with no relay lookup, and the
// pointer keeps resolving after the author edits the video (a plain `nevent`
// would rot on the next replace).
//
// Used by the share flow so a shared note can embed `nostr:naddr1…`, which
// nostr clients render as a native video quote card instead of a bare link.
import {
NIP71_NORMAL_VIDEO_KIND,
NIP71_SHORT_VIDEO_KIND,
} from "./nip71Mirror.js";
export { NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND };
const str = (value) => (typeof value === "string" ? value.trim() : "");
/**
* Which mirror kind a video's pointer should address.
*
* Prefers `kinds` — the kinds actually observed on relays (nip71MirrorService
* `findMirror`), which is ground truth. Only when that is unavailable does it
* fall back to the SAME portrait heuristic the mirror builder uses
* (nip71Mirror.js: `hasDims && height > width`), so a derived pointer matches
* the event that would be published rather than guessing independently.
*
* Returns 0 when there is nothing to point at.
*/
export function resolveMirrorKind({ kinds = [], width, height } = {}) {
const observed = (Array.isArray(kinds) ? kinds : []).filter(
(kind) => kind === NIP71_NORMAL_VIDEO_KIND || kind === NIP71_SHORT_VIDEO_KIND
);
if (observed.length === 1) {
return observed[0];
}
if (observed.length > 1) {
// Cross-kind duplicate (nip71MirrorService flags this as `duplicate`).
// Prefer the normal kind so the pointer is stable rather than arbitrary.
return observed.includes(NIP71_NORMAL_VIDEO_KIND)
? NIP71_NORMAL_VIDEO_KIND
: observed[0];
}
const w = Number(width);
const h = Number(height);
const hasDims = Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0;
if (!hasDims) {
return NIP71_NORMAL_VIDEO_KIND;
}
return h > w ? NIP71_SHORT_VIDEO_KIND : NIP71_NORMAL_VIDEO_KIND;
}
/**
* Build the `a`-tag coordinate (`kind:pubkey:d`) for a video's mirror.
* Returns "" when the inputs cannot address anything.
*/
export function buildMirrorCoordinate({ pubkey, videoRootId, kind } = {}) {
const author = str(pubkey);
const identifier = str(videoRootId);
const resolvedKind = Number(kind);
if (!author || !identifier || !Number.isFinite(resolvedKind) || resolvedKind <= 0) {
return "";
}
return `${resolvedKind}:${author}:${identifier}`;
}
/**
* Build `naddr1…` for a video's mirror.
*
* `nip19` is injected so this stays testable without a browser; callers in the
* app pass `window.NostrTools.nip19`. Returns "" on any failure — a share note
* must never embed a malformed pointer, because clients render that as a broken
* quote box, which is strictly worse than the plain-link fallback.
*
* Relay hints matter here: without them a client that doesn't already have the
* mirror has nowhere to fetch it from, and the quote card renders empty.
*/
export function buildMirrorNaddr({
pubkey,
videoRootId,
kind,
relays = [],
nip19,
} = {}) {
const author = str(pubkey);
const identifier = str(videoRootId);
const resolvedKind = Number(kind);
if (!author || !identifier || !Number.isFinite(resolvedKind) || resolvedKind <= 0) {
return "";
}
if (!nip19 || typeof nip19.naddrEncode !== "function") {
return "";
}
const relayHints = (Array.isArray(relays) ? relays : [])
.map((entry) => str(entry))
.filter((entry) => /^wss?:\/\//i.test(entry))
// A handful is plenty; an naddr carrying a user's whole relay list gets
// unwieldy in note bodies and leaks more than it helps.
.slice(0, 3);
try {
return (
nip19.naddrEncode({
kind: resolvedKind,
pubkey: author,
identifier,
relays: relayHints,
}) || ""
);
} catch (err) {
return "";
}
}
+14
View File
@@ -1465,6 +1465,17 @@ function inferMimeTypeFromUrl(url) {
// (kind 30078) can carry `imeta` variants with an `m <mime>` field just like
// NIP-71 events; a podcast/music note advertises `m audio/*`. Mirrors the
// video/audio split in nip71IngestAdapter.js so the native path stays consistent.
// HLS playlist MIME types. `audio/mpegurl` / `audio/x-mpegurl` are LEGACY
// aliases for an HLS playlist of any kind — they do NOT assert audio-only
// content, and a publisher using one for a video ladder would otherwise be
// misfiled as a podcast and dropped from the video feeds by
// isAudioOnlyVideoObject(). A playlist is a container, so it votes for neither
// side and we let the URL/`imeta m video/*` signals decide.
// Kept inline (not imported from js/services/hlsPlayback.js) so this parser
// stays free of any DOM/logger dependency — it also runs inside workers.
const HLS_PLAYLIST_MIME_PATTERN =
/^(?:application|audio|video)\/(?:x-|vnd\.apple\.)?mpegurl$/i;
function detectImetaMediaKinds(tags = []) {
let hasVideo = false;
let hasAudio = false;
@@ -1482,6 +1493,9 @@ function detectImetaMediaKinds(tags = []) {
continue;
}
const mime = trimmed.replace(/^m\s+/i, "").trim().toLowerCase();
if (HLS_PLAYLIST_MIME_PATTERN.test(mime.split(";")[0].trim())) {
continue;
}
if (mime.startsWith("video/")) {
hasVideo = true;
} else if (mime.startsWith("audio/")) {
+47
View File
@@ -23,6 +23,7 @@ import {
buildStoragePointerValue,
deriveStoragePointerFromUrl,
} from "./utils/storagePointer.js";
import { inferImageMimeTypeFromUrl } from "./utils/mime.js";
/**
* Enumeration of all supported Nostr event types in the application.
@@ -1800,6 +1801,52 @@ export function buildShareEvent(params) {
tags.push(["p", normalizedVideoPubkey, "", "mention"]);
}
// Addressable coordinate of the video's NIP-71 mirror, when one exists. Lets
// quote-aware clients and indexers link the share note to the video event
// that its body quotes via `nostr:naddr1…`.
const mirrorCoordinate =
typeof video?.mirrorCoordinate === "string"
? video.mirrorCoordinate.trim()
: "";
if (/^\d+:[0-9a-f]{64}:/i.test(mirrorCoordinate)) {
tags.push(["a", mirrorCoordinate, "", "mention"]);
}
// NIP-92 `imeta` for the thumbnail. The share content ends with the raw
// thumbnail URL, and many clients only render such a URL inline when an
// imeta entry describes it — without this the viewer just sees a bare link.
// Only emitted for http(s) thumbnails: relative paths and data: URIs are
// meaningless to a reader on another client.
//
// SKIPPED when the note quotes a mirror: the quote card already renders the
// video with its own thumbnail, and the body carries no bare image URL to
// describe. Emitting both showed the same image twice.
const quotesMirror =
typeof video?.mirrorNaddr === "string" && video.mirrorNaddr.trim() !== "";
const thumbnailUrl =
typeof video?.thumbnail === "string" ? video.thumbnail.trim() : "";
if (!quotesMirror && /^https?:\/\//i.test(thumbnailUrl)) {
const imeta = ["imeta", `url ${thumbnailUrl}`];
// Omit `m` entirely when the extension is unrecognized. A wrong media type
// is worse than a missing one — clients use it to decide whether to render
// the URL as an image at all.
const mimeType = inferImageMimeTypeFromUrl(thumbnailUrl);
if (mimeType) {
imeta.push(`m ${mimeType}`);
}
// Alt text for screen readers, and a caption fallback in clients that show
// it. Newlines would corrupt the space-delimited imeta entry, so collapse.
const altText =
typeof video?.title === "string" ? video.title.replace(/\s+/g, " ").trim() : "";
if (altText) {
imeta.push(`alt ${altText}`);
}
tags.push(imeta);
}
const seenRelayTags = new Set();
const pushRelayTag = (url, marker) => {
+236
View File
@@ -0,0 +1,236 @@
// Local-only performance measurement harness.
//
// This intentionally observes rather than changes application behavior. It is
// available only on a loopback host *and* after an explicit localStorage opt-in,
// so a production visitor cannot accidentally enable it.
const STORAGE_KEY = "__bitvidPerformanceHarness__";
const GLOBAL_KEY = "__bitvidPerformance";
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
const MAX_LONG_TASKS = 200;
const MAX_MARKS = 200;
function readStorage(storage, key) {
try {
return storage?.getItem?.(key) ?? null;
} catch (_) {
return null;
}
}
function finiteNumber(value, fallback = 0) {
return Number.isFinite(value) ? Number(value) : fallback;
}
function normalizeMarkName(name) {
return typeof name === "string" ? name.trim().slice(0, 120) : "";
}
export function isLocalPerformanceHarnessEnabled({
locationRef = typeof location !== "undefined" ? location : null,
storage = typeof localStorage !== "undefined" ? localStorage : null,
} = {}) {
const hostname = typeof locationRef?.hostname === "string"
? locationRef.hostname.toLowerCase()
: "";
return LOCAL_HOSTS.has(hostname) && readStorage(storage, STORAGE_KEY) === "1";
}
export class PerformanceHarness {
constructor({ scope, performanceRef, PerformanceObserverClass } = {}) {
this.scope = scope || null;
this.performance = performanceRef || this.scope?.performance || null;
this.PerformanceObserverClass =
PerformanceObserverClass || this.scope?.PerformanceObserver || null;
this.startedAt = 0;
this.longTasks = [];
this.marks = [];
this.gauges = new Map();
this.longTaskObserver = null;
this.started = false;
}
start() {
if (this.started) {
return this;
}
this.started = true;
this.startedAt = this.now();
this.observeLongTasks();
this.mark("harness-start");
if (this.scope) {
this.scope[GLOBAL_KEY] = this.publicApi();
}
return this;
}
stop() {
try {
this.longTaskObserver?.disconnect?.();
} catch (_) {
// Diagnostics must never affect application teardown.
}
this.longTaskObserver = null;
this.started = false;
if (this.scope?.[GLOBAL_KEY]) {
delete this.scope[GLOBAL_KEY];
}
}
now() {
return finiteNumber(this.performance?.now?.(), Date.now());
}
observeLongTasks() {
if (typeof this.PerformanceObserverClass !== "function") {
return;
}
try {
const observer = new this.PerformanceObserverClass((list) => {
const entries = list?.getEntries?.() || [];
for (const entry of entries) {
this.recordLongTask(entry);
}
});
observer.observe({ type: "longtask", buffered: true });
this.longTaskObserver = observer;
} catch (_) {
// Long Task timing is optional and unsupported in several browsers.
}
}
recordLongTask(entry) {
const task = {
startTime: finiteNumber(entry?.startTime),
duration: finiteNumber(entry?.duration),
name: typeof entry?.name === "string" ? entry.name : "longtask",
};
this.longTasks.push(task);
if (this.longTasks.length > MAX_LONG_TASKS) {
this.longTasks.splice(0, this.longTasks.length - MAX_LONG_TASKS);
}
}
mark(name, detail = {}) {
const normalizedName = normalizeMarkName(name);
if (!normalizedName) {
return false;
}
const entry = {
name: normalizedName,
at: this.now(),
detail: detail && typeof detail === "object" ? { ...detail } : {},
};
this.marks.push(entry);
if (this.marks.length > MAX_MARKS) {
this.marks.splice(0, this.marks.length - MAX_MARKS);
}
try {
this.performance?.mark?.(`bitvid:${normalizedName}`);
} catch (_) {
// User Timing is also optional; retain the local mark either way.
}
return true;
}
setGauge(name, value) {
const normalizedName = normalizeMarkName(name);
if (!normalizedName || !Number.isFinite(value)) {
return false;
}
this.gauges.set(normalizedName, Number(value));
return true;
}
increment(name, amount = 1) {
const normalizedName = normalizeMarkName(name);
if (!normalizedName || !Number.isFinite(amount)) {
return false;
}
return this.setGauge(normalizedName, (this.gauges.get(normalizedName) || 0) + amount);
}
snapshot() {
const resources = this.performance?.getEntriesByType?.("resource") || [];
const navigation = this.performance?.getEntriesByType?.("navigation")?.[0] || null;
const memory = this.performance?.memory;
const longTaskDurationMs = this.longTasks.reduce(
(total, task) => total + finiteNumber(task.duration),
0,
);
return {
enabled: this.started,
startedAt: this.startedAt,
elapsedMs: Math.max(0, this.now() - this.startedAt),
longTasks: {
count: this.longTasks.length,
totalDurationMs: longTaskDurationMs,
recent: this.longTasks.slice(-20),
},
marks: this.marks.slice(-50),
gauges: Object.fromEntries(this.gauges.entries()),
resources: {
count: resources.length,
transferSize: resources.reduce(
(total, entry) => total + finiteNumber(entry?.transferSize),
0,
),
},
navigation: navigation
? {
domContentLoadedMs: finiteNumber(navigation.domContentLoadedEventEnd),
loadEventMs: finiteNumber(navigation.loadEventEnd),
}
: null,
memory: memory
? {
usedJsHeapSize: finiteNumber(memory.usedJSHeapSize),
totalJsHeapSize: finiteNumber(memory.totalJSHeapSize),
jsHeapSizeLimit: finiteNumber(memory.jsHeapSizeLimit),
}
: null,
};
}
reset() {
this.longTasks.length = 0;
this.marks.length = 0;
this.gauges.clear();
this.mark("harness-reset");
}
publicApi() {
return Object.freeze({
snapshot: () => this.snapshot(),
mark: (name, detail) => this.mark(name, detail),
setGauge: (name, value) => this.setGauge(name, value),
increment: (name, amount) => this.increment(name, amount),
reset: () => this.reset(),
stop: () => this.stop(),
});
}
}
export function initPerformanceHarness({
scope = typeof window !== "undefined" ? window : null,
locationRef = scope?.location,
storage = scope?.localStorage,
performanceRef = scope?.performance,
PerformanceObserverClass = scope?.PerformanceObserver,
} = {}) {
if (!scope || !isLocalPerformanceHarnessEnabled({ locationRef, storage })) {
return null;
}
if (scope[GLOBAL_KEY]) {
return scope[GLOBAL_KEY];
}
const harness = new PerformanceHarness({
scope,
performanceRef,
PerformanceObserverClass,
});
harness.start();
return scope[GLOBAL_KEY];
}
export const PERFORMANCE_HARNESS_STORAGE_KEY = STORAGE_KEY;
+93 -9
View File
@@ -144,26 +144,51 @@ export default class ExploreDataService {
this.tagIdfInterval = null;
this.unsubscribeHandlers = [];
this.handleVisibility = this.handleVisibility.bind(this);
this.initialized = false;
this.active = false;
this.historyDirty = true;
this.idfDirty = true;
this.historyRefreshInFlight = null;
this.idfRefreshInFlight = null;
this.historyRefreshFollowUp = null;
this.idfRefreshFollowUp = null;
}
initialize() {
this.refreshWatchHistoryTagCounts({ force: true, reason: "init" });
this.refreshTagIdf({ force: true, reason: "init" });
initialize({ active = false } = {}) {
if (this.initialized) {
return this.setActive(active);
}
this.initialized = true;
this.subscribeToUpdates();
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", this.handleVisibility);
}
return this.setActive(active);
}
async setActive(active) {
this.active = active === true;
if (!this.active || (typeof document !== "undefined" && document.hidden)) {
this.clearIntervals();
return;
}
this.startIntervals();
const forceHistory = this.historyDirty || !this.watchHistoryTagCountsUpdatedAt;
const forceIdf = this.idfDirty || !this.tagIdfUpdatedAt;
this.historyDirty = false;
this.idfDirty = false;
await Promise.all([
this.refreshWatchHistoryTagCounts({ force: forceHistory, reason: "active" }),
this.refreshTagIdf({ force: forceIdf, reason: "active" }),
]);
}
handleVisibility() {
if (typeof document === "undefined") return;
if (document.hidden) {
this.clearIntervals();
} else {
this.refreshWatchHistoryTagCounts({ reason: "visibility" });
this.refreshTagIdf({ reason: "visibility" });
this.startIntervals();
} else if (this.active) {
void this.setActive(true);
}
}
@@ -226,6 +251,10 @@ export default class ExploreDataService {
}
queueWatchHistoryRefresh(reason) {
if (!this.active) {
this.historyDirty = true;
return;
}
if (this.watchHistoryRefreshHandle) {
return;
}
@@ -236,6 +265,10 @@ export default class ExploreDataService {
}
queueTagIdfRefresh(reason) {
if (!this.active) {
this.idfDirty = true;
return;
}
if (this.tagIdfRefreshHandle) {
return;
}
@@ -245,7 +278,33 @@ export default class ExploreDataService {
}, this.refreshDebounceMs);
}
async refreshWatchHistoryTagCounts({ force = false, reason } = {}) {
async refreshWatchHistoryTagCounts(options = {}) {
if (this.historyRefreshInFlight) {
// Keep only one fresh pass after the current worker task. Rebuilding for
// every relay/event/timer signal is exactly the burst this lifecycle is
// intended to prevent.
this.historyRefreshFollowUp = {
force: true,
reason: options.reason || "coalesced",
};
return this.historyRefreshInFlight;
}
const run = this._refreshWatchHistoryTagCounts(options);
this.historyRefreshInFlight = Promise.resolve(run).finally(() => {
this.historyRefreshInFlight = null;
const followUp = this.historyRefreshFollowUp;
this.historyRefreshFollowUp = null;
if (followUp && this.active) {
void this.refreshWatchHistoryTagCounts(followUp);
} else if (followUp) {
this.historyDirty = true;
}
});
return this.historyRefreshInFlight;
}
async _refreshWatchHistoryTagCounts({ force = false, reason } = {}) {
const now = Date.now();
if (!force && this.watchHistoryTagCountsUpdatedAt) {
const elapsed = now - this.watchHistoryTagCountsUpdatedAt;
@@ -279,7 +338,30 @@ export default class ExploreDataService {
}
}
async refreshTagIdf({ force = false, videos, reason } = {}) {
async refreshTagIdf(options = {}) {
if (this.idfRefreshInFlight) {
this.idfRefreshFollowUp = {
force: true,
reason: options.reason || "coalesced",
};
return this.idfRefreshInFlight;
}
const run = this._refreshTagIdf(options);
this.idfRefreshInFlight = Promise.resolve(run).finally(() => {
this.idfRefreshInFlight = null;
const followUp = this.idfRefreshFollowUp;
this.idfRefreshFollowUp = null;
if (followUp && this.active) {
void this.refreshTagIdf(followUp);
} else if (followUp) {
this.idfDirty = true;
}
});
return this.idfRefreshInFlight;
}
async _refreshTagIdf({ force = false, videos, reason } = {}) {
const now = Date.now();
if (!force && this.tagIdfUpdatedAt) {
const elapsed = now - this.tagIdfUpdatedAt;
@@ -332,6 +414,8 @@ export default class ExploreDataService {
clearTimeout(this.tagIdfRefreshHandle);
this.tagIdfRefreshHandle = null;
}
this.historyRefreshFollowUp = null;
this.idfRefreshFollowUp = null;
this.unsubscribeHandlers.forEach((unsubscribe) => {
if (typeof unsubscribe === "function") {
+418
View File
@@ -0,0 +1,418 @@
// HLS (.m3u8) playback support.
//
// bitvid historically attached every hosted source with `videoEl.src = url`.
// That works for progressive MP4/WebM but NOT for HLS: only Safari/iOS can play
// an .m3u8 natively, so on Chrome/Firefox the element fails immediately with
// MEDIA_ERR_SRC_NOT_SUPPORTED ("Hosted playback failed: source not supported or
// blocked."). Nostr clients that publish bitvid's v3 schema with HLS ladders
// (e.g. nostube/slidestr) were therefore unplayable everywhere but Safari.
//
// This module owns the decision + the hls.js lifecycle. hls.js is vendored
// (scripts/build-hls.mjs) and lazy-imported ONLY when an HLS URL is actually
// encountered, so the ~500KB bundle never touches a normal MP4/WebTorrent load.
//
// Rollback: stop calling attachHlsSource() from playbackService/playbackCoordinator
// and the old `videoEl.src = url` behavior returns; nothing else imports this.
import { devLogger } from "../utils/logger.js";
// Vendored, pinned hls.js bundle (scripts/build-hls.mjs). Lazy-imported.
const HLS_BUNDLE_URL = "../../vendor/hls.bundle.min.js";
// Canonical + legacy HLS media types. Servers are wildly inconsistent here —
// the slidestr CDN serves playlists as `text/plain` — so the URL extension is
// the primary signal and the MIME type is only ever corroborating evidence.
export const HLS_MIME_TYPES = Object.freeze([
"application/vnd.apple.mpegurl",
"application/x-mpegurl",
"audio/mpegurl",
"audio/x-mpegurl",
"application/mpegurl",
"vnd.apple.mpegurl",
]);
/**
* True when the URL's *path* ends in .m3u8. Query strings and fragments are
* ignored (signed CDN URLs routinely carry `?token=...`), and a bare .m3u8
* substring elsewhere in the URL must not match.
*/
export function isHlsUrl(url) {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
return false;
}
// Strip fragment then query, without `new URL()` — relative/odd inputs must
// not throw, and bitvid never runs URL parsers over user-supplied media
// strings (see the magnet rules in AGENTS.md).
const path = trimmed.split("#")[0].split("?")[0];
return /\.m3u8$/i.test(path);
}
/** True when a declared MIME type is one of the HLS playlist types. */
export function isHlsMimeType(mimeType) {
const normalized =
typeof mimeType === "string" ? mimeType.trim().toLowerCase() : "";
if (!normalized) {
return false;
}
const bare = normalized.split(";")[0].trim();
return HLS_MIME_TYPES.includes(bare);
}
/** Combined check used by callers that may have an imeta `m` hint. */
export function isHlsSource({ url = "", mimeType = "" } = {}) {
return isHlsUrl(url) || isHlsMimeType(mimeType);
}
/**
* Whether `canPlayType` claims native HLS support.
*
* DO NOT branch on this alone. Chrome LIES here: Chrome 149 returns "maybe" for
* every HLS MIME type, but assigning an .m3u8 to `video.src` then fires NO
* events at all — not `loadeddata`, not even `error`. The element simply hangs,
* which is exactly the silent "nothing happens" failure this module exists to
* fix (and why the URL-health probe timed out instead of erroring, leaving the
* card visible but unplayable). Use prefersNativeHls() to make decisions.
*/
export function supportsNativeHls(videoElement) {
const el =
videoElement ||
(typeof document !== "undefined" ? document.createElement("video") : null);
if (!el || typeof el.canPlayType !== "function") {
return false;
}
for (const type of HLS_MIME_TYPES) {
try {
if (el.canPlayType(type)) {
return true;
}
} catch (err) {
// canPlayType must never throw, but never let a probe kill playback.
}
}
return false;
}
// A representative baseline codec: every MSE implementation that can run hls.js
// supports fragmented-MP4 H.264. This mirrors what Hls.isSupported() checks, so
// we can predict the engine choice WITHOUT paying to load the bundle (the
// URL-health probe needs the answer for every visible card).
const MSE_PROBE_TYPE = 'video/mp4; codecs="avc1.42E01E"';
/** True when Media Source Extensions can back hls.js in this browser. */
export function supportsMse() {
const MediaSourceImpl =
(typeof globalThis !== "undefined" &&
(globalThis.MediaSource ||
globalThis.ManagedMediaSource ||
globalThis.WebKitMediaSource)) ||
null;
if (!MediaSourceImpl || typeof MediaSourceImpl.isTypeSupported !== "function") {
return false;
}
try {
return MediaSourceImpl.isTypeSupported(MSE_PROBE_TYPE);
} catch (err) {
return false;
}
}
/**
* Whether to use the browser's own HLS implementation instead of hls.js.
*
* Only when MSE is genuinely unavailable (older iOS Safari, some TV browsers).
* hls.js is the default everywhere else — this is hls.js's own recommended
* ordering, and on Chrome it is mandatory: see supportsNativeHls() for the
* canPlayType lie that makes a native-first branch hang forever.
*/
export function prefersNativeHls(videoElement) {
return !supportsMse() && supportsNativeHls(videoElement);
}
let hlsEnginePromise = null;
/**
* Lazy-import the vendored hls.js bundle. Cached across calls (including the
* rejection case, deliberately reset so a transient network failure can retry).
*/
export async function loadHlsEngine() {
if (!hlsEnginePromise) {
hlsEnginePromise = import(HLS_BUNDLE_URL)
.then((mod) => mod?.default || mod?.Hls || null)
.catch((err) => {
hlsEnginePromise = null;
devLogger.warn("[hlsPlayback] Failed to load hls.js bundle:", err);
return null;
});
}
return hlsEnginePromise;
}
/** Test seam: drop the cached engine promise. */
export function __resetHlsEngineCacheForTests() {
hlsEnginePromise = null;
}
// videoEl -> active hls.js instance. A WeakMap keeps teardown paths (which only
// ever hold the element) from having to thread an extra handle around, and lets
// a discarded element be collected without leaking the instance.
const attachedInstances = new WeakMap();
export function getAttachedHls(videoElement) {
return videoElement ? attachedInstances.get(videoElement) || null : null;
}
/**
* Tear down any hls.js instance bound to this element. Safe to call
* unconditionally on every reset path — it is a no-op for non-HLS playback.
*/
export function detachHls(videoElement) {
if (!videoElement) {
return false;
}
const hls = attachedInstances.get(videoElement);
if (!hls) {
return false;
}
attachedInstances.delete(videoElement);
try {
hls.destroy();
} catch (err) {
devLogger.warn("[hlsPlayback] hls.destroy() threw during detach:", err);
}
return true;
}
function describeHlsError(data) {
const type = data?.type || "unknown";
const details = data?.details || "unknown";
if (data?.type === "networkError") {
return "Hosted playback failed: the HLS stream could not be reached (network or CORS).";
}
if (data?.type === "mediaError") {
return "Hosted playback failed: this HLS stream uses a codec your browser cannot decode.";
}
return `Hosted playback failed: HLS error (${type}/${details}).`;
}
/**
* Attach `url` to `videoElement`, using native HLS where available and hls.js
* (MSE) otherwise.
*
* Resolves once the manifest is parsed — the meaningful "this is playable"
* signal for both the player and the URL-health probe. Callers are still
* responsible for calling play(); this only wires up the source.
*
* Returns null when HLS cannot be supported at all (no native support and MSE
* unavailable), so the caller can fall back to its normal `src` assignment and
* surface a real media error.
*
* `createEngine` is a test seam: it resolves the hls.js constructor and defaults
* to the lazy import of the vendored bundle. Tests inject a fake so the MSE
* branch can be specified without a real MediaSource.
*
* @returns {Promise<{mode: "native"|"mse", destroy: () => void}|null>}
*/
export async function attachHlsSource(
videoElement,
url,
{
onFatalError,
log = () => {},
manifestTimeoutMs = 15000,
createEngine = loadHlsEngine,
} = {}
) {
if (!videoElement) {
return null;
}
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
return null;
}
// Always clear a previous instance before rebinding — reusing the modal's
// <video> across videos is the norm in bitvid.
detachHls(videoElement);
const useNative = () => {
log("[hlsPlayback] Using native HLS playback.");
videoElement.src = trimmed;
return { mode: "native", destroy: () => {} };
};
// Native only when MSE genuinely cannot back hls.js. Never trust canPlayType
// on its own — Chrome claims HLS support and then hangs silently.
if (prefersNativeHls(videoElement)) {
return useNative();
}
const Hls = await createEngine();
if (!Hls || typeof Hls.isSupported !== "function" || !Hls.isSupported()) {
// Last resort: if the bundle failed to load but the browser claims native
// support, an attempt beats a guaranteed failure.
if (supportsNativeHls(videoElement)) {
log("[hlsPlayback] hls.js unavailable; falling back to native HLS.");
return useNative();
}
log("[hlsPlayback] hls.js unavailable/unsupported; cannot play HLS.");
return null;
}
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
// Keep a bounded back-buffer so long VOD (this note is ~1h) does not grow
// the SourceBuffer without limit on low-memory devices.
backBufferLength: 90,
});
attachedInstances.set(videoElement, hls);
let settled = false;
let networkRetries = 0;
let mediaRecoveries = 0;
return new Promise((resolve) => {
let timeoutId = null;
const finish = (value) => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
resolve(value);
};
const fail = (message) => {
// Only tear down once we've given up; recoverable errors keep the
// instance alive so hls.js can heal itself.
detachHls(videoElement);
if (typeof onFatalError === "function") {
onFatalError(message);
}
finish(null);
};
if (Number.isFinite(manifestTimeoutMs) && manifestTimeoutMs > 0) {
timeoutId = setTimeout(() => {
if (settled) {
return;
}
log("[hlsPlayback] Manifest parse timed out.");
fail("Hosted playback failed: the HLS manifest did not load in time.");
}, manifestTimeoutMs);
}
hls.on(Hls.Events.MANIFEST_PARSED, (_event, data) => {
log(
`[hlsPlayback] Manifest parsed; ${data?.levels?.length ?? 0} playable level(s).`
);
// hls.js drops levels whose codecs MSE cannot decode (e.g. an HEVC
// rendition on Firefox). Zero survivors means nothing is playable.
if (Array.isArray(data?.levels) && data.levels.length === 0) {
fail(
"Hosted playback failed: no HLS rendition uses a codec your browser supports."
);
return;
}
finish({ mode: "mse", destroy: () => detachHls(videoElement) });
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data?.fatal) {
log(
`[hlsPlayback] Non-fatal HLS error (${data?.type}/${data?.details}); continuing.`
);
return;
}
log(`[hlsPlayback] Fatal HLS error (${data?.type}/${data?.details}).`);
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && networkRetries < 2) {
networkRetries += 1;
log(`[hlsPlayback] Retrying load (attempt ${networkRetries}).`);
try {
hls.startLoad();
return;
} catch (err) {
log("[hlsPlayback] startLoad() threw during recovery.", err);
}
}
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && mediaRecoveries < 2) {
mediaRecoveries += 1;
log(`[hlsPlayback] Recovering media error (attempt ${mediaRecoveries}).`);
try {
if (mediaRecoveries === 2 && typeof hls.swapAudioCodec === "function") {
hls.swapAudioCodec();
}
hls.recoverMediaError();
return;
} catch (err) {
log("[hlsPlayback] recoverMediaError() threw during recovery.", err);
}
}
fail(describeHlsError(data));
});
try {
hls.attachMedia(videoElement);
hls.loadSource(trimmed);
} catch (err) {
log("[hlsPlayback] attachMedia/loadSource threw:", err);
fail("Hosted playback failed: the HLS stream could not be attached.");
}
});
}
/**
* Cheap playability check for the URL-health badge that does NOT pull in hls.js.
*
* On MSE browsers hls.js fetches the playlist with XHR, so a CORS-readable
* `#EXTM3U` response is exactly the condition for playability — and a bare
* <video> probe (the old path) would report every HLS URL as dead. On Safari,
* native playback needs no CORS, so the caller should keep using the element
* probe there.
*
* @returns {Promise<"ok"|"error"|"timeout">}
*/
export async function probeHlsManifest(url, timeoutMs = 4000) {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed || typeof fetch !== "function") {
return "error";
}
const controller =
typeof AbortController === "function" ? new AbortController() : null;
let timedOut = false;
const timeoutId =
Number.isFinite(timeoutMs) && timeoutMs > 0
? setTimeout(() => {
timedOut = true;
controller?.abort();
}, timeoutMs)
: null;
try {
const response = await fetch(trimmed, {
method: "GET",
cache: "no-store",
signal: controller?.signal,
});
if (!response.ok) {
return "error";
}
const text = await response.text();
return text.trimStart().startsWith("#EXTM3U") ? "ok" : "error";
} catch (err) {
return timedOut ? "timeout" : "error";
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
+68 -3
View File
@@ -41,6 +41,9 @@ const MAX_OPEN_ATTEMPTS = 30; // ~60s of retries
// (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;
// Some relays do not reliably send EOSE. Do not hold the first visible update
// forever, but give the initial relay burst time to settle before painting it.
const INITIAL_SYNC_FALLBACK_MS = 5000;
export function createNip71IngestService({
nostrClient,
@@ -54,6 +57,7 @@ export function createNip71IngestService({
maxOpenAttempts = MAX_OPEN_ATTEMPTS,
refreshThrottleMs = REFRESH_THROTTLE_MS,
feedReadyFallbackMs = FEED_READY_FALLBACK_MS,
initialSyncFallbackMs = INITIAL_SYNC_FALLBACK_MS,
} = {}) {
let subscription = null;
let buffer = [];
@@ -67,6 +71,9 @@ export function createNip71IngestService({
let deferArmed = false;
let deferTimer = null;
let offFeedReady = null;
let initialSyncing = false;
let initialRefreshPending = false;
let initialSyncTimer = null;
function isAvailable() {
return (
@@ -183,7 +190,13 @@ export function createNip71IngestService({
);
if (injected > 0) {
requestRefresh();
if (initialSyncing) {
// Keep native BitVid, NIP-71 videos, and future NIP-71 media types in
// the store immediately; defer only the expensive full-feed repaint.
initialRefreshPending = true;
} else {
requestRefresh();
}
}
return injected;
@@ -211,6 +224,10 @@ export function createNip71IngestService({
function emitRefresh() {
lastRefreshAt = Date.now();
const performanceHarness = globalThis?.__bitvidPerformance;
const performanceRef = globalThis?.performance;
const startedAt = typeof performanceRef?.now === "function" ? performanceRef.now() : 0;
performanceHarness?.mark?.("nip71-refresh-filter-start");
let videos = [];
try {
videos =
@@ -220,6 +237,11 @@ export function createNip71IngestService({
} catch (error) {
logger?.warn?.("[nip71Ingest] getFilteredActiveVideos failed", error);
}
const filteredAt = typeof performanceRef?.now === "function" ? performanceRef.now() : startedAt;
performanceHarness?.mark?.("nip71-refresh-filter-end", {
durationMs: Math.max(0, filteredAt - startedAt),
videos: videos.length,
});
// Confirm ingested videos survive the feed filter (whitelist/NSFW/etc.).
const foreignCount = Array.isArray(videos)
? videos.filter((v) => v?.source === "nip71-ingest").length
@@ -234,6 +256,37 @@ export function createNip71IngestService({
reason: "nip71-ingest",
});
}
const emittedAt = typeof performanceRef?.now === "function" ? performanceRef.now() : filteredAt;
performanceHarness?.mark?.("nip71-refresh-dispatch-end", {
filterMs: Math.max(0, filteredAt - startedAt),
dispatchMs: Math.max(0, emittedAt - filteredAt),
videos: videos.length,
});
if (performanceHarness && (filteredAt - startedAt > 50 || emittedAt - filteredAt > 50)) {
userLogger.info(
`[nip71Ingest] refresh timing: filter ${Math.round(filteredAt - startedAt)}ms, dispatch ${Math.round(emittedAt - filteredAt)}ms, videos ${videos.length}`,
);
}
}
function finishInitialSync() {
if (!initialSyncing) {
return;
}
if (initialSyncTimer) {
clearTimeout(initialSyncTimer);
initialSyncTimer = null;
}
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
flush();
}
initialSyncing = false;
if (initialRefreshPending) {
initialRefreshPending = false;
requestRefresh();
}
}
// Defer the first start until the native feed has rendered (first
@@ -255,7 +308,7 @@ export function createNip71IngestService({
deferTimer = null;
}
deferArmed = false;
start();
start({ coalesceInitialSync: true });
};
if (typeof nostrService?.on === "function") {
@@ -265,12 +318,17 @@ export function createNip71IngestService({
return true;
}
function start() {
function start({ coalesceInitialSync = false } = {}) {
if (started || !isAvailable()) {
return false;
}
started = true;
attemptsLeft = maxOpenAttempts;
initialSyncing = coalesceInitialSync;
initialRefreshPending = false;
if (initialSyncing) {
initialSyncTimer = setTimeout(finishInitialSync, initialSyncFallbackMs);
}
// Re-subscribe when the whitelist changes so the author scope stays current.
if (typeof accessControl?.onWhitelistChange === "function") {
@@ -320,6 +378,7 @@ export function createNip71IngestService({
scheduleFlush();
}
},
onEose: finishInitialSync,
});
const authorCount = Array.isArray(filters[0]?.authors)
? filters[0].authors.length
@@ -348,6 +407,12 @@ export function createNip71IngestService({
clearTimeout(pendingRefreshTimer);
pendingRefreshTimer = null;
}
if (initialSyncTimer) {
clearTimeout(initialSyncTimer);
initialSyncTimer = null;
}
initialSyncing = false;
initialRefreshPending = false;
buffer = [];
if (subscription && typeof subscription.close === "function") {
try {
+49 -29
View File
@@ -1071,6 +1071,12 @@ export class NostrService {
this.dmActorPubkey = normalizedActor;
const relaySelection = await this.resolveDirectMessageRelaySelection(
normalizedActor,
relays,
);
const resolvedRelays = relaySelection.relays;
let resolvedSince =
Number.isFinite(sinceCandidate) && sinceCandidate >= 0
? Math.floor(sinceCandidate)
@@ -1095,7 +1101,7 @@ export class NostrService {
let messages = [];
try {
messages = await this.nostrClient.listDirectMessages(normalizedActor, {
relays,
relays: resolvedRelays,
...options,
limit: resolvedLimit,
decryptLimit: resolvedDecryptLimit,
@@ -1133,6 +1139,14 @@ export class NostrService {
messages: snapshot,
actorPubkey: normalizedActor,
});
if (relaySelection.warning === DM_RELAY_WARNING_FALLBACK) {
this.emit("directMessages:relayWarning", {
actorPubkey: normalizedActor,
warning: relaySelection.warning,
relays: resolvedRelays,
source: relaySelection.source,
});
}
this.emit("directMessages:updated", {
messages: snapshot,
reason: "load",
@@ -1156,6 +1170,35 @@ export class NostrService {
return snapshot;
}
async resolveDirectMessageRelaySelection(actorPubkey, relays = null) {
if (Array.isArray(relays)) {
return { relays, source: "explicit", warning: null };
}
const discoveryRelays = Array.isArray(this.nostrClient?.readRelays)
? this.nostrClient.readRelays
: Array.isArray(this.nostrClient?.relays)
? this.nostrClient.relays
: [];
let pool = this.nostrClient?.pool || null;
if (!pool && typeof this.nostrClient?.ensurePool === "function") {
try {
pool = await this.nostrClient.ensurePool();
} catch (error) {
devLogger.warn("[nostrService] Failed to initialize DM relay pool.", error);
}
}
return resolveDmRelaySelection({
pubkey: actorPubkey,
discoveryRelays,
fallbackRelays: discoveryRelays,
pool,
log: { dev: devLogger, user: userLogger },
});
}
async ensureDirectMessageSubscription({ actorPubkey, relays, ...handlers } = {}) {
const activeActor = actorPubkey || this.resolveActiveDmActor();
const normalizedActor = normalizeHexPubkey(activeActor);
@@ -1189,34 +1232,11 @@ export class NostrService {
this.stopDirectMessageSubscription();
}
let resolvedRelays = Array.isArray(relays) ? relays : null;
let relaySelection = null;
if (!resolvedRelays) {
const discoveryRelays = Array.isArray(this.nostrClient?.readRelays)
? this.nostrClient.readRelays
: Array.isArray(this.nostrClient?.relays)
? this.nostrClient.relays
: [];
let pool = this.nostrClient?.pool || null;
if (!pool && typeof this.nostrClient?.ensurePool === "function") {
try {
pool = await this.nostrClient.ensurePool();
} catch (error) {
devLogger.warn("[nostrService] Failed to initialize DM relay pool.", error);
}
}
relaySelection = await resolveDmRelaySelection({
pubkey: normalizedActor,
discoveryRelays,
fallbackRelays: discoveryRelays,
pool,
log: { dev: devLogger, user: userLogger },
});
resolvedRelays = relaySelection.relays;
}
const relaySelection = await this.resolveDirectMessageRelaySelection(
normalizedActor,
relays,
);
const resolvedRelays = relaySelection.relays;
try {
const subscription = this.nostrClient.subscribeDirectMessages(
+115 -8
View File
@@ -1,5 +1,8 @@
import { userLogger } from "../utils/logger.js";
import { PLAYBACK_START_TIMEOUT } from "../constants.js";
import {
HLS_PLAYBACK_START_TIMEOUT,
PLAYBACK_START_TIMEOUT,
} from "../constants.js";
import { getCurrentVideo, setCurrentVideo } from "../state/appState.js";
import {
consumeAutoplayFallbackMute,
@@ -9,6 +12,7 @@ import {
SimpleEventEmitter,
extractWebSeedsFromMagnet,
} from "./playbackHelpers.js";
import { attachHlsSource, detachHls, isHlsUrl } from "./hlsPlayback.js";
// js/services/playbackService.js
/**
@@ -855,6 +859,10 @@ class PlaybackSession extends SimpleEventEmitter {
const resetVideoElement = () => {
if (!activeVideoEl) return;
// Kill any hls.js instance BEFORE touching src/load(): a live instance
// keeps appending to the MediaSource and would fight the reset (and
// leak its worker) when we move to the next mirror or to WebTorrent.
detachHls(activeVideoEl);
try {
activeVideoEl.pause();
} catch (err) {
@@ -1055,6 +1063,11 @@ class PlaybackSession extends SimpleEventEmitter {
};
});
// Whatever MediaError the element already carries from a previous
// load. Identity comparison works because starting a new load clears
// `error` to null and a fresh failure allocates a new MediaError.
const staleMediaError = activeVideoEl.error || null;
const attachWatchdogs = ({ stallMs = 8000 } = {}) => {
this.registerWatchdogs(activeVideoEl, {
stallMs,
@@ -1071,7 +1084,17 @@ class PlaybackSession extends SimpleEventEmitter {
attachWatchdogs({ stallMs: 0 });
return;
}
const errorMessage = getHostedVideoErrorMessage(activeVideoEl);
// Only report a MediaError this attempt actually produced.
// teardownVideoElement()/resetVideoElement() clear `src` and
// call load(), which leaves a stale MEDIA_ERR_SRC_NOT_SUPPORTED
// ("Empty src attribute") on the element. Reading it blindly
// reported "Hosted playback failed: source not supported or
// blocked." for a stream that was playing perfectly — a
// failure message attributed to the wrong load entirely.
const errorMessage =
activeVideoEl.error && activeVideoEl.error !== staleMediaError
? getHostedVideoErrorMessage(activeVideoEl)
: "";
if (errorMessage) {
this.emit("status", { message: errorMessage });
}
@@ -1080,9 +1103,6 @@ class PlaybackSession extends SimpleEventEmitter {
});
};
// Use default stall timeout here; the initial start timeout is handled by the race wrapper
attachWatchdogs({ stallMs: 8000 });
const handleFatalPlaybackError = (err) => {
this.service.log(
"[playVideoWithFallback] Direct URL playback threw:",
@@ -1091,6 +1111,14 @@ class PlaybackSession extends SimpleEventEmitter {
outcomeResolver({ status: "fallback", reason: "play-error" });
};
// Bind the source BEFORE arming the watchdogs. hls.js's attachMedia()
// points `src` at a MediaSource blob URL, which aborts any load still
// pending on the reused modal element and fires `abort`/`emptied`.
// With watchdogs already live those read as a playback failure and
// abandon a stream that is about to play fine. A plain `src =` is
// safe either way: media events are dispatched asynchronously, so
// listeners registered later in the same task still observe them.
let hlsAttached = true;
try {
// Guarantee the direct load is CORS-free regardless of how we got
// here (see resetVideoElement) so any hosted URL plays reliably.
@@ -1100,12 +1128,65 @@ class PlaybackSession extends SimpleEventEmitter {
// ignore
}
activeVideoEl.crossOrigin = null;
activeVideoEl.src = candidateUrl;
const playPromise = activeVideoEl.play();
// HLS cannot be assigned to `src` outside Safari — it needs hls.js
// + MSE. attachHlsSource() handles both paths and resolves once the
// manifest is parsed. A null result means HLS is unplayable here
// (or already failed fatally), and the outcome promise is settled,
// so skip play() and let the normal fallback plumbing take over.
if (isHlsUrl(candidateUrl)) {
const hlsHandle = await attachHlsSource(
activeVideoEl,
candidateUrl,
{
log: (...args) => this.service.log(...args),
onFatalError: (message) => {
this.emit("status", { message });
outcomeResolver({
status: "fallback",
reason: "hls-error",
errorMessage: message,
});
},
}
);
if (!hlsHandle) {
hlsAttached = false;
// A fatal HLS error already resolved the outcome with a
// specific message; this only covers "no HLS support at all".
const message =
"Hosted playback failed: this browser cannot play HLS streams.";
this.emit("status", { message });
outcomeResolver({
status: "fallback",
reason: "hls-unsupported",
errorMessage: message,
});
}
} else {
activeVideoEl.src = candidateUrl;
}
} catch (err) {
hlsAttached = false;
handleFatalPlaybackError(err);
}
try {
// Use default stall timeout here; the initial start timeout is
// handled by the race wrapper.
if (hlsAttached) {
attachWatchdogs({ stallMs: 8000 });
}
const playPromise = hlsAttached ? activeVideoEl.play() : null;
if (playPromise && typeof playPromise.catch === "function") {
playPromise.catch((err) => {
if (err?.name === "NotAllowedError") {
autoplayBlocked = true;
// Suspend the start timeout: we are waiting on the viewer,
// not on a slow source, and falling back to P2P here strands
// them on a swarm that may have no peers.
startTimeoutGuard.deferred = true;
this.service.log(
"[playVideoWithFallback] Autoplay blocked by browser; awaiting user interaction.",
err
@@ -1119,6 +1200,7 @@ class PlaybackSession extends SimpleEventEmitter {
activeVideoEl.removeEventListener("play", restoreOnPlay);
this.cleanupWatchdog();
autoplayBlocked = false;
startTimeoutGuard.deferred = false;
attachWatchdogs({ stallMs: 8000 });
};
activeVideoEl.addEventListener("play", restoreOnPlay, {
@@ -1171,10 +1253,29 @@ class PlaybackSession extends SimpleEventEmitter {
};
// --- Timeout Wrapper ---
// `startTimeoutGuard.deferred` is set when the browser blocks autoplay and
// we are waiting on a user gesture. That is NOT a stalled source, and
// treating it as one was breaking mobile outright: a deep link from a
// nostr client arrives with no user gesture, so autoplay is refused, the
// 3s start timeout fired anyway, and a video with a magnet got yanked into
// WebTorrent -- which then found no peers and played nothing, while the
// "Press play" prompt the viewer was meant to act on had already been
// torn down. Desktop usually hides this because an established Media
// Engagement score lets autoplay through.
const startTimeoutGuard = { deferred: false };
const withTimeout = (promise, ms, label = "Operation") => {
if (!ms || ms <= 0) return promise;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (startTimeoutGuard.deferred) {
// Waiting on the viewer, not on the network. Let the underlying
// attempt settle on its own: it resolves when they press play, or
// when a real media error/stall fires.
this.service.log(
`[playVideoWithFallback] ${label} start timeout suppressed; autoplay is blocked and we are awaiting a user gesture.`
);
return;
}
this.service.log(`[playVideoWithFallback] ${label} timed out after ${ms}ms.`);
resolve({ status: "fallback", reason: "timeout", source: "timeout" });
}, ms);
@@ -1215,9 +1316,15 @@ class PlaybackSession extends SimpleEventEmitter {
let lastUrlResult = null;
for (let i = 0; i < this.hostedSourceCandidates.length; i += 1) {
const candidateUrl = this.hostedSourceCandidates[i];
// HLS starts slower by construction (see HLS_PLAYBACK_START_TIMEOUT).
// `forcedSource` still means "wait indefinitely", so keep 0 as 0.
const candidateTimeout =
effectiveTimeout && isHlsUrl(candidateUrl)
? Math.max(effectiveTimeout, HLS_PLAYBACK_START_TIMEOUT)
: effectiveTimeout;
const urlResult = await withTimeout(
attemptHostedPlayback(candidateUrl),
effectiveTimeout,
candidateTimeout,
"URL Playback"
);
+31 -49
View File
@@ -180,6 +180,7 @@ class SubscriptionsManager {
this.subscribedPubkeys = new Set();
this.subsEventId = null;
this.subsEventCreatedAt = null;
this.subsEventPubkey = null;
// Last live-subscription event id we triggered a refresh for (loop guard).
this._lastHandledListEventId = null;
// Most recently fetched list ciphertext, reused by decrypt retries so they
@@ -247,6 +248,7 @@ class SubscriptionsManager {
this.subscribedPubkeys = new Set(cachedSnapshot.subscribedPubkeys);
this.subsEventId = cachedSnapshot.eventId;
this.subsEventCreatedAt = cachedSnapshot.createdAt;
this.subsEventPubkey = cachedSnapshot.eventPubkey;
this.currentUserPubkey = normalizedUserPubkey;
this.uiReady = true;
this.dataReady = true;
@@ -281,6 +283,7 @@ class SubscriptionsManager {
subscribedPubkeys: Array.from(this.subscribedPubkeys),
eventId: this.subsEventId,
createdAt: this.subsEventCreatedAt,
eventPubkey: this.subsEventPubkey,
});
}
@@ -389,63 +392,39 @@ class SubscriptionsManager {
profileCache.getProfileData(normalizedUserPubkey, "subscriptions"),
);
hadCachedSnapshot = cachedSnapshot.hasSnapshot;
const shouldForceFullFetch = !cachedSnapshot.hasSnapshot;
// Legacy snapshots did not record the source author. Fetch their full
// authoritative state once so a cache created by an older buggy build
// cannot keep suppressing the real list with an invalid timestamp.
const shouldForceFullFetch =
!cachedSnapshot.hasSnapshot ||
cachedSnapshot.eventPubkey !== normalizedUserPubkey;
const incrementalSince = shouldForceFullFetch
? 0
: computeIncrementalSinceWithOverlap(cachedSnapshot.createdAt, 1);
// Fetch user and session actor subscription lists in parallel to reduce
// total relay wait time during login.
const fetchPromises = [
nostrClient.fetchListIncrementally({
kind: SUBSCRIPTION_SET_KIND,
pubkey: normalizedUserPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls,
since: incrementalSince,
timeoutMs: 12000,
}),
];
const normalizedSessionActorPubkey = normalizeNostrPubkey(
nostrClient?.sessionActor?.pubkey,
);
const shouldFetchSessionActor =
normalizedSessionActorPubkey &&
normalizedSessionActorPubkey !== normalizedUserPubkey;
if (shouldFetchSessionActor) {
const sessionCachedSnapshot = parseCachedSubscriptionSnapshot(
profileCache.getProfileData(
normalizedSessionActorPubkey,
"subscriptions",
),
);
const shouldForceSessionFetch = !sessionCachedSnapshot.hasSnapshot;
const sessionSince = shouldForceSessionFetch
? 0
: computeIncrementalSinceWithOverlap(sessionCachedSnapshot.createdAt, 1);
fetchPromises.push(
nostrClient.fetchListIncrementally({
kind: SUBSCRIPTION_SET_KIND,
pubkey: normalizedSessionActorPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls,
since: sessionSince,
timeoutMs: 12000,
}),
);
}
const fetchResults = await Promise.all(fetchPromises);
let events = fetchResults[0] || [];
if (shouldFetchSessionActor && fetchResults[1]?.length) {
events = events.concat(fetchResults[1]);
}
// Subscription lists are private, account-scoped state. In particular,
// a BitLogin session actor can differ from the active profile pubkey.
// Never mix its events into this read: choosing the newest event across
// both accounts could overwrite the active account's subscriptions.
const events = await nostrClient.fetchListIncrementally({
kind: SUBSCRIPTION_SET_KIND,
pubkey: normalizedUserPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls,
since: incrementalSince,
timeoutMs: 12000,
});
if (events.length) {
const deduped = new Map();
for (const event of events) {
if (!event || typeof event !== "object" || !event.id) {
if (
!event ||
typeof event !== "object" ||
!event.id ||
(event.pubkey &&
normalizeHexPubkey(event.pubkey) !== normalizedUserPubkey)
) {
continue;
}
const existing = deduped.get(event.id);
@@ -471,6 +450,7 @@ class SubscriptionsManager {
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.subsEventCreatedAt = null;
this.subsEventPubkey = null;
this.uiReady = true;
this.dataReady = true;
this.loaded = true;
@@ -513,6 +493,7 @@ class SubscriptionsManager {
this.subsEventCreatedAt = Number.isFinite(newest.created_at)
? newest.created_at
: null;
this.subsEventPubkey = normalizedUserPubkey;
let decryptResult;
try {
@@ -687,6 +668,7 @@ class SubscriptionsManager {
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.subsEventCreatedAt = null;
this.subsEventPubkey = null;
this._lastHandledListEventId = null;
this._pendingDecryptEvent = null;
this.currentUserPubkey = null;
+4
View File
@@ -196,6 +196,7 @@ export function parseCachedSubscriptionSnapshot(cached) {
subscribedPubkeys: normalized,
eventId: null,
createdAt: null,
eventPubkey: null,
hasSnapshot: true,
};
}
@@ -220,6 +221,7 @@ export function parseCachedSubscriptionSnapshot(cached) {
const createdAt = Number.isFinite(createdAtCandidate)
? Math.floor(createdAtCandidate)
: null;
const eventPubkey = normalizeHexPubkey(cached.eventPubkey);
const hasSnapshot =
Array.isArray(cached.subscribedPubkeys) ||
Array.isArray(cached.subPubkeys) ||
@@ -229,6 +231,7 @@ export function parseCachedSubscriptionSnapshot(cached) {
subscribedPubkeys: normalized,
eventId,
createdAt,
eventPubkey,
hasSnapshot,
};
}
@@ -237,6 +240,7 @@ export function parseCachedSubscriptionSnapshot(cached) {
subscribedPubkeys: [],
eventId: null,
createdAt: null,
eventPubkey: null,
hasSnapshot: false,
};
}
+17 -14
View File
@@ -166,11 +166,10 @@ export default class ApplicationBootstrap {
app.streamHealthSnapshots = new Map();
app.boundStreamHealthBadgeHandler = (detail) =>
app.handleStreamHealthBadgeUpdate(detail);
app.attachHealthBadgesWithCache = (container) => {
app.attachHealthBadgesWithCache = (container) =>
attachHealthBadges(container, {
onUpdate: app.boundStreamHealthBadgeHandler,
});
};
app.defaultModerationSettings = getDefaultModerationSettings();
app.moderationSettings = { ...app.defaultModerationSettings };
app.relayManager = relayManager;
@@ -360,18 +359,22 @@ export default class ApplicationBootstrap {
onViewLogged: (detail) => app.handleFeedViewTelemetry(detail),
});
app.exploreDataService =
this.services.exploreDataService ||
new ExploreDataService({
watchHistoryService,
nostrService: app.nostrService,
getActiveActor: () =>
typeof app.pubkey === "string" && app.pubkey ? app.pubkey : "",
logger: devLogger,
});
if (app.exploreDataService && typeof app.exploreDataService.initialize === "function") {
app.exploreDataService.initialize();
}
app.exploreDataService = this.services.exploreDataService || null;
app.ensureExploreDataService = async () => {
if (!app.exploreDataService) {
app.exploreDataService = new ExploreDataService({
watchHistoryService,
nostrService: app.nostrService,
getActiveActor: () =>
typeof app.pubkey === "string" && app.pubkey ? app.pubkey : "",
logger: devLogger,
});
}
if (typeof app.exploreDataService.initialize === "function") {
await app.exploreDataService.initialize({ active: true });
}
return app.exploreDataService;
};
const playbackDependencies = {
torrentClient: this.services.torrentClient || torrentClient,
+23 -1
View File
@@ -351,8 +351,28 @@ export class ShareNostrModal {
typeof payload?.shareUrl === "string" ? payload.shareUrl.trim() : "";
const thumbnail =
typeof payload?.thumbnail === "string" ? payload.thumbnail.trim() : "";
const mirrorNaddr =
typeof payload?.mirrorNaddr === "string" ? payload.mirrorNaddr.trim() : "";
return `${title} — Check out this video on bitvid 👇\n\n${shareUrl}\n\n${thumbnail}`;
const lines = [`${title} — Check out this video on bitvid 👇`];
if (shareUrl) {
lines.push(shareUrl);
}
if (mirrorNaddr) {
// A `nostr:naddr…` reference renders as a NATIVE video quote card in
// nostr clients — the closest thing to a rich preview that doesn't
// depend on any client fetching OpenGraph tags. The raw thumbnail URL is
// deliberately omitted here: the quote card already shows the thumbnail,
// and including both rendered the same image twice.
lines.push(`nostr:${mirrorNaddr}`);
} else if (thumbnail) {
// No mirror to quote — fall back to the bare thumbnail URL, which the
// note's NIP-92 `imeta` tag describes so clients inline it as an image.
lines.push(thumbnail);
}
return lines.join("\n\n");
}
setVideo(payload) {
@@ -380,6 +400,8 @@ export class ShareNostrModal {
title,
shareUrl,
thumbnail,
mirrorNaddr:
typeof payload?.mirrorNaddr === "string" ? payload.mirrorNaddr : "",
});
}
+26
View File
@@ -3453,6 +3453,32 @@ export class VideoModal {
// Delegated click handler for the share popover's menu-action buttons
// (Copy URL / Copy Magnet / Copy CDN / Share on Nostr). The forced-source
// test links self-wire their own copy, so they are skipped here.
/**
* Update the login/signer gate for the share menu's "Share on Nostr" item.
*
* This state was initialized to `false` and never written, so
* `createVideoShareMenuPanel` always rendered the item disabled — the entry
* looked like inert text no matter who was logged in, and the click
* delegation in bindShareMenuActions() bails on `button.disabled`. The app
* pushes real state here from syncAuthUiState().
*/
setShareNostrAuthState({ isLoggedIn = false, hasSigner = false } = {}) {
const next = { isLoggedIn: !!isLoggedIn, hasSigner: !!hasSigner };
const changed =
next.isLoggedIn !== this.shareNostrAuthState.isLoggedIn ||
next.hasSigner !== this.shareNostrAuthState.hasSigner;
this.shareNostrAuthState = next;
// The popover renders its panel on open, so a menu that is already open
// would keep showing the stale (disabled) item. Close it so the next open
// renders against current state.
if (changed) {
this.modalSharePopover?.close?.();
}
return changed;
}
bindShareMenuActions(panel) {
if (!panel || typeof panel.addEventListener !== "function") {
return;
@@ -753,7 +753,11 @@ export class ProfileDirectMessageActions {
handleDirectMessagesUpdated(detail = {}) {
if (
this.controller.activeMessagesRequest &&
detail?.reason !== "load-incremental"
detail?.reason !== "load-incremental" &&
// A persisted, locally encrypted snapshot is safe to show while the
// slower relay history/decryption pass is still running. Suppressing it
// made the DM pane appear blank until that full pass completed.
detail?.reason !== "snapshot"
) {
return;
}
@@ -79,6 +79,8 @@ export class ProfilePlaylistsController {
this.playlists = [];
this.expanded = new Set();
this.loading = false;
this.refreshRequestId = 0;
this.fetchCreatorPlaylists = fetchCreatorPlaylists;
this._drag = null;
this._dropTarget = null;
this._onChanged = () => {
@@ -139,16 +141,22 @@ export class ProfilePlaylistsController {
return;
}
const requestId = ++this.refreshRequestId;
this.setLoading(true);
let playlists = [];
try {
playlists = await fetchCreatorPlaylists(this.pubkey, {
playlists = await this.fetchCreatorPlaylists(this.pubkey, {
includeEmpty: true,
});
} catch (error) {
devLogger.warn("[playlists] Failed to load playlists for pane:", error);
}
this.playlists = playlists;
this.setLoading(false);
// Show the playlist structure as soon as the list itself is available.
// Resolving every referenced video only improves labels/thumbnails and
// must not keep the management pane blank on a cold cache.
this.render();
// Fetch the referenced creators' videos so the expanded sublists can show
// real titles/thumbnails (cache may be cold in the modal).
@@ -165,15 +173,16 @@ export class ProfilePlaylistsController {
}
const nostrService = this.mainController.services?.nostrService;
if (authors.size && typeof nostrService?.fetchVideosByAuthors === "function") {
try {
await nostrService.fetchVideosByAuthors([...authors]);
} catch (error) {
// best effort — fall back to coordinate labels
}
void nostrService.fetchVideosByAuthors([...authors])
.catch(() => {
// Best effort — keep the coordinate labels if metadata is unavailable.
})
.then(() => {
if (this.refreshRequestId === requestId) {
this.render();
}
});
}
this.setLoading(false);
this.render();
}
// coord -> { title, thumbnail } from the app's active videos.
+114 -1
View File
@@ -11,6 +11,16 @@ import { queueSignEvent as defaultQueueSignEvent } from "../nostr/signRequestQue
import { DEFAULT_NIP07_PERMISSION_METHODS as defaultPermissionMethods } from "../nostr/nip07Permissions.js";
import { nostrClient as defaultNostrClient } from "../nostrClientFacade.js";
import { userLogger as defaultUserLogger, devLogger as defaultDevLogger } from "../utils/logger.js";
import { nip71MirrorService as defaultMirrorService } from "../services/nip71MirrorService.js";
import {
buildMirrorCoordinate,
buildMirrorNaddr,
resolveMirrorKind,
} from "../nostr/mirrorPointer.js";
// Best-effort: a share must never hang on a relay read. If the mirror lookup
// doesn't answer in time we simply fall back to the thumbnail-URL note.
const MIRROR_LOOKUP_TIMEOUT_MS = 2500;
export default class ShareNostrController {
constructor({ ui, state, services = {} }) {
@@ -27,6 +37,87 @@ export default class ShareNostrController {
nostrClient: services.nostrClient || defaultNostrClient,
userLogger: services.userLogger || defaultUserLogger,
devLogger: services.devLogger || defaultDevLogger,
mirrorService:
services.mirrorService === undefined
? defaultMirrorService
: services.mirrorService,
nip19: services.nip19 || null,
};
}
resolveNip19() {
return (
this.services.nip19 ||
(typeof window !== "undefined" ? window?.NostrTools?.nip19 : null) ||
null
);
}
/**
* Resolve an `naddr` for the video's NIP-71 mirror, or "" when there isn't
* one. Embedding this makes nostr clients render a native video quote card
* instead of a bare link — but ONLY when the mirror actually exists on
* relays. A pointer to a missing event renders as a broken/empty quote box,
* which is worse than the plain-link fallback, so this verifies before
* pointing and stays silent on any failure.
*/
async resolveMirrorPointer(video) {
const mirrorService = this.services.mirrorService;
const pubkey = typeof video?.pubkey === "string" ? video.pubkey.trim() : "";
const videoRootId =
typeof video?.videoRootId === "string" ? video.videoRootId.trim() : "";
if (!pubkey || !videoRootId || !mirrorService) {
return { naddr: "", coordinate: "" };
}
if (
typeof mirrorService.isAvailable === "function" &&
!mirrorService.isAvailable()
) {
return { naddr: "", coordinate: "" };
}
if (typeof mirrorService.findMirror !== "function") {
return { naddr: "", coordinate: "" };
}
let result = null;
try {
result = await Promise.race([
mirrorService.findMirror({ pubkey, videoRootId }),
new Promise((resolve) =>
setTimeout(() => resolve(null), MIRROR_LOOKUP_TIMEOUT_MS)
),
]);
} catch (error) {
this.services.devLogger.warn(
"[ShareNostrController] Mirror lookup failed; sharing without a quote.",
error
);
return { naddr: "", coordinate: "" };
}
if (!result?.mirrored) {
return { naddr: "", coordinate: "" };
}
const kind = resolveMirrorKind({
kinds: result.kinds,
width: video?.width,
height: video?.height,
});
const relays = Array.isArray(this.services.nostrClient?.writeRelays)
? this.services.nostrClient.writeRelays
: this.services.nostrClient?.relays || [];
return {
naddr: buildMirrorNaddr({
pubkey,
videoRootId,
kind,
relays,
nip19: this.resolveNip19(),
}),
coordinate: buildMirrorCoordinate({ pubkey, videoRootId, kind }),
};
}
@@ -52,6 +143,8 @@ export default class ShareNostrController {
? targetVideo.shareUrl.trim()
: this.state.buildShareUrlFromEventId(targetVideo.id);
const mirror = await this.resolveMirrorPointer(targetVideo);
const payload = {
id: targetVideo.id,
title: targetVideo.title,
@@ -59,6 +152,10 @@ export default class ShareNostrController {
authorName: targetVideo.creatorName || targetVideo.authorName || "",
thumbnail: targetVideo.thumbnail,
shareUrl,
// Present only when a NIP-71 mirror was confirmed on relays. Drives the
// quote-card note body and the `a` tag on the published event.
mirrorNaddr: mirror.naddr,
mirrorCoordinate: mirror.coordinate,
};
try {
@@ -167,7 +264,23 @@ export default class ShareNostrController {
pubkey: eventPubkey,
created_at: Math.floor(Date.now() / 1000),
content: typeof payload?.content === "string" ? payload.content : "",
video: { id: videoId, pubkey: videoPubkey },
// title/thumbnail drive the NIP-92 `imeta` hint so the thumbnail URL in
// the note body renders as an image rather than a bare link. When a
// mirror pointer is present the note quotes the mirror instead, and
// buildShareEvent drops the imeta so the image isn't rendered twice.
video: {
id: videoId,
pubkey: videoPubkey,
title: videoTitle,
thumbnail:
typeof video?.thumbnail === "string" ? video.thumbnail.trim() : "",
mirrorNaddr:
typeof video?.mirrorNaddr === "string" ? video.mirrorNaddr.trim() : "",
mirrorCoordinate:
typeof video?.mirrorCoordinate === "string"
? video.mirrorCoordinate.trim()
: "",
},
relays: relayEntries,
});
+20
View File
@@ -1,4 +1,9 @@
import { SHORT_TIMEOUT_MS } from "../constants.js";
import {
isHlsUrl,
prefersNativeHls,
probeHlsManifest,
} from "../services/hlsPlayback.js";
export default class UrlHealthController {
constructor({
@@ -275,6 +280,21 @@ export default class UrlHealthController {
return { outcome: "error" };
}
// A bare <video> cannot load an .m3u8 on the browsers bitvid actually runs
// hls.js on. Worse, Chrome neither plays it NOR errors — it emits no events
// at all, so this probe burned the full timeout on every healthy HLS card.
// Since playback will use hls.js (which fetches the playlist over CORS), a
// readable #EXTM3U response is the accurate signal — and far cheaper than
// pulling in the hls.js bundle just to paint a badge.
if (isHlsUrl(trimmed) && !prefersNativeHls()) {
const defaultTimeout =
this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS;
const effective =
Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : defaultTimeout;
const outcome = await probeHlsManifest(trimmed, effective);
return { outcome };
}
if (this.activeVideoProbes >= this.MAX_CONCURRENT_VIDEO_PROBES) {
await new Promise((resolve) => {
this.videoProbeQueue.push(resolve);
+22 -3
View File
@@ -32,6 +32,8 @@ export class VideoListView {
(typeof window !== "undefined" ? window : null);
this.container = null;
this.mediaLoader = mediaLoader;
this.healthBadgeDetach = null;
this.urlHealthBadgeDetach = null;
this.badgeHelpers = {
attachHealthBadges: badgeHelpers.attachHealthBadges || (() => {}),
@@ -236,6 +238,7 @@ export class VideoListView {
}
if (this.container) {
this.teardownHealthBadgeObservers();
this.container.removeEventListener("click", this._boundClickHandler);
}
@@ -262,6 +265,7 @@ export class VideoListView {
const markup = this.renderers.getLoadingMarkup(message);
if (typeof markup === "string") {
this.teardownHealthBadgeObservers();
this.container.innerHTML = markup;
}
}
@@ -280,6 +284,16 @@ export class VideoListView {
}
}
teardownHealthBadgeObservers() {
for (const detach of [this.healthBadgeDetach, this.urlHealthBadgeDetach]) {
if (typeof detach === "function") {
detach();
}
}
this.healthBadgeDetach = null;
this.urlHealthBadgeDetach = null;
}
setPlaybackHandler(handler) {
this.handlers.playback = typeof handler === "function" ? handler : null;
}
@@ -374,6 +388,7 @@ export class VideoListView {
return displayVideos;
}
this.lastRenderedVideoSignature = EMPTY_VIDEO_LIST_SIGNATURE;
this.teardownHealthBadgeObservers();
this.container.innerHTML = `
<p class="flex justify-center items-center h-full w-full text-center text-subtle">
No public videos available yet. Be the first to upload one!
@@ -413,7 +428,10 @@ export class VideoListView {
}));
const signature = JSON.stringify(signaturePayload);
if (signature === this.lastRenderedVideoSignature) {
const hasRenderedCards = Boolean(
this.container.querySelector('[data-component="video-card"]'),
);
if (signature === this.lastRenderedVideoSignature && hasRenderedCards) {
return displayVideos;
}
this.lastRenderedVideoSignature = signature;
@@ -788,11 +806,12 @@ export class VideoListView {
});
}
this.teardownHealthBadgeObservers();
this.container.innerHTML = "";
this.container.appendChild(fragment);
this.badgeHelpers.attachHealthBadges(this.container);
this.badgeHelpers.attachUrlHealthBadges(this.container, ({ badgeEl, url, eventId }) => {
this.healthBadgeDetach = this.badgeHelpers.attachHealthBadges(this.container);
this.urlHealthBadgeDetach = this.badgeHelpers.attachUrlHealthBadges(this.container, ({ badgeEl, url, eventId }) => {
this.utils.handleUrlHealthBadge({
video: this.state.videosMap.get(eventId) || { id: eventId },
url,
+2 -2
View File
@@ -75,12 +75,12 @@ function triggerUrlCheck(card, state) {
export function attachUrlHealthBadges(container, onCheck) {
if (!(container instanceof HTMLElement)) {
return;
return () => {};
}
const state = urlCardObserver.observe(container);
if (state && typeof onCheck === "function") {
state.onCheck = onCheck;
}
return () => urlCardObserver.disconnect(container);
}
+53
View File
@@ -47,3 +47,56 @@ export function inferMimeTypeFromUrl(url) {
const mimeType = EXTENSION_MIME_MAP[extension];
return typeof mimeType === "string" ? mimeType : "";
}
// Image types, kept in a SEPARATE map from EXTENSION_MIME_MAP on purpose: that
// map gates video upload/publish paths (js/nostr/publishHelpers.js,
// js/nostr/videoPayloadBuilder.js), so folding image types into it would let an
// image pass a video check. Used for NIP-92 `imeta` hints on thumbnails.
export const IMAGE_EXTENSION_MIME_MAP = Object.freeze(
Object.fromEntries(
Object.entries({
jpg: "image/jpeg",
jpeg: "image/jpeg",
jfif: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
bmp: "image/bmp",
svg: "image/svg+xml",
ico: "image/x-icon",
heic: "image/heic",
heif: "image/heif",
}).map(([extension, mimeType]) => [extension, mimeType.toLowerCase()]),
),
);
/**
* Best-effort image MIME type from a URL's extension. Returns "" when the URL
* carries no recognizable image extension — callers must treat that as "unknown"
* and omit the hint rather than guessing, since a wrong `m` value is worse than
* a missing one (clients use it to decide whether to render inline at all).
*/
export function inferImageMimeTypeFromUrl(url) {
if (!url || typeof url !== "string") {
return "";
}
let pathname = "";
try {
const parsed = new URL(url);
pathname = parsed.pathname || "";
} catch (err) {
const sanitized = url.split("?")[0].split("#")[0];
pathname = sanitized || "";
}
const lastSegment = pathname.split("/").pop() || "";
const match = lastSegment.match(/\.([a-z0-9]+)$/i);
if (!match) {
return "";
}
const mimeType = IMAGE_EXTENSION_MIME_MAP[match[1].toLowerCase()];
return typeof mimeType === "string" ? mimeType : "";
}
+27 -9
View File
@@ -138,6 +138,29 @@ export class TorrentClient {
let timeoutId = null;
let pollId = null;
// A successful probe commonly settles from WebTorrent's `wire` event.
// Do not destroy the temporary torrent while that event is still being
// dispatched: WebTorrent continues its handshake bookkeeping after
// listeners return, and synchronous destruction can leave its bitfield
// null (`recalculate(...).fill` in the bundled client). The next task
// guarantees the callback has fully unwound before cleanup.
const disposeProbeTorrent = (target, onDisposed = () => {}) => {
if (!target) {
onDisposed();
return;
}
const dispose = () => {
try {
target.destroy({ destroyStore: true });
} catch (err) {
// A completed or already-destroyed probe is harmless.
} finally {
onDisposed();
}
};
setTimeout(dispose, 0);
};
const finalize = (overrides = {}) => {
if (settled) {
return;
@@ -151,14 +174,6 @@ export class TorrentClient {
clearInterval(pollId);
pollId = null;
}
if (torrent) {
try {
torrent.destroy({ destroyStore: true });
} catch (err) {
// ignore
}
}
const endedAt =
typeof performance !== "undefined" && performance?.now
? performance.now()
@@ -178,7 +193,10 @@ export class TorrentClient {
...overrides,
};
emit("torrent-probe-result", result);
resolve(result);
// Keep the grid-health queue slot occupied until the delayed teardown
// completes. Otherwise the next probe can begin before this probe has
// detached its tracker listeners, briefly exceeding listener limits.
disposeProbeTorrent(torrent, () => resolve(result));
};
try {
+43
View File
@@ -0,0 +1,43 @@
// Routes crawler requests for `/?v=<nevent>` to the per-video OpenGraph
// renderer (api/og.js).
//
// This has to be middleware rather than a `rewrites` entry in vercel.json:
// rewrites are evaluated AFTER the filesystem, and `/` matches the static
// index.html, so the rewrite never fired and crawlers kept getting the
// site-wide card. Middleware runs before the filesystem check.
//
// Deliberately minimal. It matches only `/`, bails immediately when there is no
// `v` param or the visitor is not a known preview crawler, and never touches
// the response for a real viewer — they continue to the untouched SPA. If this
// file is removed the site keeps working, just with the generic card.
import { next, rewrite } from "@vercel/edge";
import { isCrawlerUserAgent } from "./api/_lib/ogRenderer.mjs";
export const config = {
// Only the share-link path. Every other route skips middleware entirely.
matcher: "/",
};
export default function middleware(request) {
let url;
try {
url = new URL(request.url);
} catch (err) {
return next();
}
// A bare homepage visit is not a share link.
if (!url.searchParams.get("v")) {
return next();
}
const userAgent = request.headers?.get?.("user-agent") || "";
if (!isCrawlerUserAgent(userAgent)) {
return next();
}
const target = new URL(url.toString());
target.pathname = "/api/og";
return rewrite(target);
}
-46
View File
@@ -1,46 +0,0 @@
[build]
command = "npm run build && npm run verify:dist:deploy-artifact"
publish = "dist"
[build.environment]
NODE_ENV = "production"
[[headers]]
for = "/*"
[headers.values]
Content-Security-Policy = "frame-ancestors *"
[[headers]]
for = "/index.html"
[headers.values]
Cache-Control = "no-cache, must-revalidate"
[[headers]]
for = "/embed.html"
[headers.values]
Cache-Control = "no-cache, must-revalidate"
[[headers]]
for = "/*.html"
[headers.values]
Cache-Control = "no-cache, must-revalidate"
[[headers]]
for = "/css/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
[[headers]]
for = "/js/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
[[headers]]
for = "/sw.min.js"
[headers.values]
Cache-Control = "public, max-age=0, must-revalidate"
[[headers]]
for = "/site.webmanifest"
[headers.values]
Cache-Control = "public, max-age=0, must-revalidate"
+15
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@floating-ui/dom": "^1.7.4",
"@tailwindcss/typography": "^0.5.19",
"@vercel/edge": "^1.3.1",
"autoprefixer": "^10.4.24",
"cross-env": "^10.1.0",
"esbuild": "^0.27.3",
@@ -25,6 +26,7 @@
"blossom-client-sdk": "5.0.0",
"cssnano": "^7.1.2",
"fake-indexeddb": "^6.2.5",
"hls.js": "^1.6.16",
"jsdom": "^28.1.0",
"nostr-wasm": "^0.1.0",
"pixelmatch": "^5.3.0",
@@ -1477,6 +1479,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@vercel/edge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vercel/edge/-/edge-1.3.1.tgz",
"integrity": "sha512-k0tKWeYEWOv6qU6+Z8+hv2Nt/u3vFHRZdB0uvwE0DxoddU/5kWX/Chu8F6ojvqmRlnZwuX/7kyZFVeCP6DxqBg==",
"license": "Apache-2.0"
},
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
@@ -3252,6 +3260,13 @@
"node": ">= 0.4"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/hookified": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
+3
View File
@@ -17,6 +17,7 @@
"build:beacon": "npm run build:beacon:bundle && npm run lint:inline-styles",
"build:bitcoin-connect": "node scripts/build-bitcoin-connect.mjs",
"build:blossom-sdk": "node scripts/build-blossom-sdk.mjs",
"build:hls": "node scripts/build-hls.mjs",
"ci:beacon": "npm run build:beacon",
"format": "prettier --cache --write css/tailwind.source.css css/tokens.css css/docs.css \"docs/**/*.html\" tailwind.config.cjs README.md CONTRIBUTING.md",
"audit": "node scripts/daily-design-system-audit.mjs",
@@ -65,6 +66,7 @@
"blossom-client-sdk": "5.0.0",
"cssnano": "^7.1.2",
"fake-indexeddb": "^6.2.5",
"hls.js": "^1.6.16",
"jsdom": "^28.1.0",
"nostr-wasm": "^0.1.0",
"pixelmatch": "^5.3.0",
@@ -83,6 +85,7 @@
"dependencies": {
"@floating-ui/dom": "^1.7.4",
"@tailwindcss/typography": "^0.5.19",
"@vercel/edge": "^1.3.1",
"autoprefixer": "^10.4.24",
"cross-env": "^10.1.0",
"esbuild": "^0.27.3",
-3357
View File
File diff suppressed because it is too large. Load diff
+38 -1
View File
@@ -33,6 +33,43 @@ if (packageJson.name !== "@bitlogin/widget") {
throw new Error("BITLOGIN_ENTRY must point to a BitLogin checkout (packages/widget/package.json not found there).");
}
// Provenance (BitUnlock entropy audit M1, ported). Hashes prove the vendored
// bytes have not changed since they were committed; they cannot say WHICH
// BitLogin commit produced them. @bitlogin/widget is a private package pinned
// at 0.1.0 across many substantive changes, so the version string distinguishes
// nothing. Resolved BEFORE the dist build and before vendorDir is deleted, so
// refusing a dirty checkout leaves the committed vendored files untouched.
const git = (args) => execSync(`git ${args}`, { cwd: bitloginRoot, encoding: "utf8" }).trim();
let source;
try {
const dirty = git("status --porcelain") !== "";
if (dirty && process.env.BITLOGIN_ALLOW_DIRTY !== "1") {
throw new Error(
"DIRTY_CHECKOUT: the BitLogin checkout has uncommitted changes, so the artifact this "
+ "would produce corresponds to no commit and cannot be reproduced. Commit there first, "
+ "or set BITLOGIN_ALLOW_DIRTY=1 for a throwaway local build.",
);
}
source = {
repository: git("remote get-url origin"),
commit: git("rev-parse HEAD"),
commitDate: git("log -1 --format=%cI"),
dirty,
provenance: dirty ? "dirty" : "attributed",
toolchain: { node: process.version, vite: packageJson.devDependencies?.vite },
};
} catch (error) {
if (error.message.startsWith("DIRTY_CHECKOUT:")) {
throw new Error(error.message.replace("DIRTY_CHECKOUT: ", ""));
}
throw new Error(
`Could not read BitLogin git provenance from ${bitloginRoot}: ${error.message}. `
+ "Vendoring from a non-git source leaves the artifact unattributable; refusing.",
);
}
console.log(`[vendor-bitlogin] vendoring @bitlogin/widget from ${source.commit.slice(0, 12)}`);
if (!existsSync(widgetDist)) {
console.log("[build-bitlogin-widget] dist missing, building @bitlogin/core then @bitlogin/widget first...");
execSync("npm run build -w @bitlogin/core", { cwd: bitloginRoot, stdio: "inherit" });
@@ -56,7 +93,7 @@ for (const name of distEntries) {
await writeFile(
integrityFile,
`${JSON.stringify({ package: packageJson.name, version: packageJson.version, files }, null, 2)}\n`,
`${JSON.stringify({ package: packageJson.name, version: packageJson.version, source, files }, null, 2)}\n`,
);
try {
+46
View File
@@ -0,0 +1,46 @@
// Bundles hls.js into a single self-contained ESM file that bitvid lazy-imports
// the first time a viewer opens an HLS (.m3u8) source. Mirrors the
// blossom-sdk / bitcoin-connect / floating-ui vendor steps. Pinned via the exact
// devDependency version in package.json.
//
// Lazy-import is the whole point: hls.js is ~530KB minified, and the vast
// majority of bitvid videos are progressive MP4 or WebTorrent. Nothing here may
// be imported from a module that loads on boot.
import { build } from "esbuild";
import { mkdir, copyFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const entryPoint = path.join(repoRoot, "scripts/vendor/hls-entry.mjs");
const vendorDir = path.join(repoRoot, "vendor");
const outFile = path.join(vendorDir, "hls.bundle.min.js");
const licenseSrc = path.join(repoRoot, "node_modules/hls.js/LICENSE");
const licenseOut = path.join(vendorDir, "hls.LICENSE");
await mkdir(vendorDir, { recursive: true });
await build({
bundle: true,
entryPoints: [entryPoint],
outfile: outFile,
format: "esm",
splitting: false,
platform: "browser",
target: ["es2020"],
minify: true,
sourcemap: false,
define: {
"process.env.NODE_ENV": '"production"',
global: "globalThis",
},
logLevel: "info",
});
try {
await copyFile(licenseSrc, licenseOut);
} catch (error) {
console.warn("[build-hls] could not copy LICENSE:", error.message);
}
console.log("[build-hls] wrote", path.relative(repoRoot, outFile));
+6
View File
@@ -0,0 +1,6 @@
// Vendor entry for hls.js. We deliberately bundle the FULL build rather than
// `hls.js/light`: the light build drops the alternate-audio stream controller,
// and real-world HLS ladders (e.g. nostube/slidestr publishes an
// `EXT-X-MEDIA:TYPE=AUDIO` rendition group) demux audio into its own playlist.
// With the light build those streams play video-only or not at all.
export { default } from "hls.js";
+58
View File
@@ -85,3 +85,61 @@ test('createFeedCoordinator - loadForYouVideos', async (t) => {
assert.strictEqual(app.videoSubscription, 'mock-subscription', 'Should update videoSubscription');
});
});
test("loadForYouVideos renders without waiting for recommendation worker activation", async () => {
let resolveActivation;
const activation = new Promise((resolve) => {
resolveActivation = resolve;
});
let loadVideosStarted = false;
const mockDeps = {
devLogger: { log: mock.fn(), warn: mock.fn() },
userLogger: { warn: mock.fn() },
nostrClient: {},
getSidebarLoadingMarkup: mock.fn(() => '<div>Loading...</div>'),
watchHistoryService: {},
subscriptions: {},
createActiveNostrSource: mock.fn(),
createBlacklistFilterStage: mock.fn(),
createDisinterestFilterStage: mock.fn(),
createDedupeByRootStage: mock.fn(),
createExploreDiversitySorter: mock.fn(),
createExploreScorerStage: mock.fn(),
createKidsAudienceFilterStage: mock.fn(),
createKidsScorerStage: mock.fn(),
createKidsScoreSorter: mock.fn(),
createModerationStage: mock.fn(),
createResolvePostedAtStage: mock.fn(),
createTagPreferenceFilterStage: mock.fn(),
createWatchHistorySuppressionStage: mock.fn(),
createChronologicalSorter: mock.fn(),
createSubscriptionAuthorsSource: mock.fn(),
registerWatchHistoryFeed: mock.fn(),
};
const coordinator = createFeedCoordinator(mockDeps);
const app = {
...coordinator,
nostrService: {
getFilteredActiveVideos: mock.fn(() => [{ id: "cached" }]),
loadVideos: mock.fn(async ({ onVideos }) => {
loadVideosStarted = true;
onVideos([{ id: "cached", title: "Cached", created_at: 1 }], { reason: "cache" });
return [{ id: "cached", title: "Cached", created_at: 1 }];
}),
getVideoSubscription: mock.fn(() => null),
getVideosMap: mock.fn(() => new Map()),
},
videoListView: { state: {}, render: mock.fn() },
mountVideoListView: mock.fn(() => ({ innerHTML: "" })),
checkRelayHealthWarning: mock.fn(),
setFeedTelemetryContext: mock.fn(),
isAuthorBlocked: mock.fn(() => false),
refreshFeed: mock.fn(async () => {}),
ensureExploreDataService: mock.fn(() => activation),
blacklistedEventIds: new Set(),
};
await app.loadForYouVideos(false);
assert.equal(loadVideosStarted, true);
resolveActivation();
});
+34
View File
@@ -178,3 +178,37 @@ test("login reloads the open video comment thread", async () => {
assert.deepEqual(loadedVideos, [activeVideo]);
});
test("duplicate login for the active identity joins the existing list sync", async () => {
const coord = makeCoordinator({
loadDirectMessages: async () => ({ ok: true }),
refreshUnreadDmIndicator: async () => {},
});
let releaseBlocks;
const blocksGate = new Promise((resolve) => {
releaseBlocks = resolve;
});
let blockLoads = 0;
coord.authService.loadBlocksForPubkey = async () => {
blockLoads += 1;
await blocksGate;
return true;
};
const detail = {
pubkey: ACTIVE_PUBKEY,
identityChanged: false,
postLogin: { profile: { name: "tester" } },
postLoginPromise: Promise.resolve({ profile: { name: "tester" } }),
};
await coord.handleAuthLogin(detail);
const duplicate = coord.handleAuthLogin({ ...detail });
for (let attempt = 0; attempt < 20 && blockLoads === 0; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.equal(blockLoads, 1, "duplicate login must not start a second list sync");
releaseBlocks();
await duplicate;
assert.equal(coord._authLoginSync, null, "dedupe guard releases after list sync settles");
});
+70
View File
@@ -0,0 +1,70 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
installRequestMonitor,
isRequestMonitorEnabled,
REQUEST_MONITOR_STORAGE_KEY,
} from "../js/devReqMonitor.js";
function storage(value = null) {
return { getItem: (key) => (key === REQUEST_MONITOR_STORAGE_KEY ? value : null) };
}
function createScope({ hostname = "localhost", enabled = false } = {}) {
class FakeWebSocket {
send(data) {
this.sent = data;
return "sent";
}
}
const intervals = [];
return {
location: { hostname },
localStorage: storage(enabled ? "1" : null),
WebSocket: FakeWebSocket,
setInterval: (callback, delay) => {
intervals.push({ callback, delay });
return intervals.length;
},
clearInterval: () => {},
console: { info: () => {}, warn: () => {} },
intervals,
};
}
test("request monitor stays disabled on localhost until explicitly enabled", () => {
const scope = createScope();
const originalSend = scope.WebSocket.prototype.send;
assert.equal(installRequestMonitor({ scope }), null);
assert.equal(scope.WebSocket.prototype.send, originalSend);
assert.equal(scope.intervals.length, 0);
});
test("request monitor cannot be enabled on a public host", () => {
assert.equal(
isRequestMonitorEnabled({
locationRef: { hostname: "unstable.bitvid.network" },
storage: storage("1"),
}),
false,
);
});
test("explicit loopback opt-in patches REQ sends and can be stopped", () => {
const scope = createScope({ enabled: true });
const originalSend = scope.WebSocket.prototype.send;
const monitor = installRequestMonitor({ scope });
assert.ok(monitor);
assert.notEqual(scope.WebSocket.prototype.send, originalSend);
assert.equal(scope.intervals.length, 1);
assert.equal(scope.intervals[0].delay, 2000);
const socket = new scope.WebSocket();
assert.equal(socket.send('["REQ","sub",{"kinds":[1]}]'), "sent");
assert.equal(socket.sent, '["REQ","sub",{"kinds":[1]}]');
monitor.stop();
assert.equal(scope.WebSocket.prototype.send, originalSend);
assert.equal(scope.__bitvidReqMonitorInstalled, undefined);
});
+45
View File
@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ProfileDirectMessageActions } from "../js/ui/profileModal/ProfileDirectMessageActions.js";
test("DM pane renders a persisted snapshot while the relay load is still active", () => {
const scheduled = [];
const actor = "a".repeat(64);
const controller = {
activeMessagesRequest: Symbol("relay-load"),
directMessagesCache: [],
directMessagesLastActor: null,
helper: { resolveActiveDmActor: () => actor },
renderer: {
scheduleDirectMessagesRender: (detail) => scheduled.push(detail),
setMessagesLoadingState: () => {},
},
};
const actions = new ProfileDirectMessageActions({}, controller);
const messages = [{ event: { id: "cached-message" } }];
actions.handleDirectMessagesUpdated({ messages, reason: "snapshot" });
assert.deepEqual(controller.directMessagesCache, messages);
assert.equal(controller.directMessagesLastActor, actor);
assert.deepEqual(scheduled, [{ messages, actorPubkey: actor, reason: "snapshot" }]);
});
test("DM pane waits for relay increments but ignores unrelated updates during a load", () => {
const controller = {
activeMessagesRequest: Symbol("relay-load"),
directMessagesCache: [],
directMessagesLastActor: null,
helper: { resolveActiveDmActor: () => "a".repeat(64) },
renderer: {
scheduleDirectMessagesRender: () => assert.fail("unrelated update must not render"),
setMessagesLoadingState: () => {},
},
};
const actions = new ProfileDirectMessageActions({}, controller);
actions.handleDirectMessagesUpdated({ messages: [{ event: { id: "other" } }], reason: "load" });
assert.deepEqual(controller.directMessagesCache, []);
});
+38
View File
@@ -0,0 +1,38 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { createCardObserver } from "../../js/dom/cardObserver.js";
test("card observer disconnect unregisters every card and releases its observer", () => {
const dom = new JSDOM("<div id=grid><article class='card' data-video-id=a></article><article class='card' data-video-id=b></article></div>");
const originalHTMLElement = globalThis.HTMLElement;
const originalIntersectionObserver = globalThis.IntersectionObserver;
globalThis.HTMLElement = dom.window.HTMLElement;
let disconnects = 0;
globalThis.IntersectionObserver = class {
observe() {}
takeRecords() { return []; }
disconnect() { disconnects += 1; }
};
const unregistered = [];
try {
const observer = createCardObserver({
onCardUnregister: ({ card }) => unregistered.push(card.dataset.videoId),
});
const grid = dom.window.document.getElementById("grid");
observer.observe(grid);
observer.disconnect(grid);
assert.deepEqual(unregistered.sort(), ["a", "b"]);
assert.equal(disconnects, 1);
assert.equal(observer.getState(grid), null);
} finally {
dom.window.close();
if (originalHTMLElement === undefined) delete globalThis.HTMLElement;
else globalThis.HTMLElement = originalHTMLElement;
if (originalIntersectionObserver === undefined) delete globalThis.IntersectionObserver;
else globalThis.IntersectionObserver = originalIntersectionObserver;
}
});
+17 -4
View File
@@ -13,12 +13,20 @@ test.describe("runtime utility module coverage", () => {
"/js/search/searchFilters.js"
);
// 9afbc0cb ("Decode npub authors to hex") made the author filter validate
// its value, because npub-form authors silently matched nothing. The old
// fixture used `author:abc123`, which is neither a decodable npub nor
// 64-char hex, so the filter is now (correctly) rejected and
// authorPubkeys comes back empty. Use a real pubkey to exercise the
// success path, and pin the rejection separately below.
const AUTHOR_HEX = "a".repeat(64);
const parsed = parseFilterQuery(
'author:abc123 tag:#music,nostr kind:30078 relay:wss://relay.example after:2024-01-02 before:1707000000 duration:>=1.5m has:magnet nsfw:safe "exact phrase" looseTerm',
`author:${AUTHOR_HEX} tag:#music,nostr kind:30078 relay:wss://relay.example after:2024-01-02 before:1707000000 duration:>=1.5m has:magnet nsfw:safe "exact phrase" looseTerm`,
);
const withErrors = parseFilterQuery(
"duration:oops has:bad unknown:value before:not-a-date",
"duration:oops has:bad unknown:value before:not-a-date author:abc123",
);
const serialized = serializeFiltersToQuery(parsed.filters);
@@ -30,7 +38,7 @@ test.describe("runtime utility module coverage", () => {
};
});
expect(result.parsed.filters.authorPubkeys).toEqual(["abc123"]);
expect(result.parsed.filters.authorPubkeys).toEqual(["a".repeat(64)]);
expect(result.parsed.filters.tags).toEqual(["music", "nostr"]);
expect(result.parsed.filters.kind).toBe(30078);
expect(result.parsed.filters.relay).toBe("wss://relay.example");
@@ -44,8 +52,13 @@ test.describe("runtime utility module coverage", () => {
expect(result.withErrors.errors.some((e: any) => /Duration/.test(e.message))).toBe(true);
expect(result.withErrors.errors.some((e: any) => /Has filter/.test(e.message))).toBe(true);
expect(result.withErrors.errors.some((e: any) => /Date value/.test(e.message))).toBe(true);
// An undecodable author must be reported rather than silently dropped.
expect(
result.withErrors.errors.some((e: any) => /Could not decode author/.test(e.message)),
).toBe(true);
expect(result.withErrors.filters.authorPubkeys).toEqual([]);
expect(result.serialized).toContain("author:abc123");
expect(result.serialized).toContain(`author:${"a".repeat(64)}`);
expect(result.serialized).toContain("tag:music");
expect(result.serialized).toContain("kind:30078");
expect(result.serialized).toContain("has:magnet");
+20 -2
View File
@@ -491,7 +491,21 @@ test.describe("nostr runtime coverage (deterministic)", () => {
createdAt: 42,
}),
);
// A legacy plaintext entry must be PURGED, not honored. readV1Entry()
// removes it from storage; readStoredSessionActorEntry() then falls back
// to the sole remaining (encrypted) account, so the old `=== null`
// assertion no longer describes the security property -- it just happened
// to hold before 8692dac6 added per-account storage. Assert the property
// directly instead: no plaintext key survives, in the returned entry or
// in raw storage.
const migratedLegacyEntry = readStoredSessionActorEntry();
const legacyRaw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY);
let legacyRawPlaintextSurvives = false;
try {
legacyRawPlaintextSurvives = Boolean(legacyRaw && JSON.parse(legacyRaw)?.privateKey);
} catch (error) {
legacyRawPlaintextSurvives = false;
}
const actorMatch = isSessionActor({
pubkey: actor,
@@ -524,7 +538,10 @@ test.describe("nostr runtime coverage (deterministic)", () => {
wrongPassphraseErrorCode,
metadataValid: metadata && metadata.salt === encrypted.salt,
metadataInvalidIsNull: invalidMetadata === null,
migratedLegacyEntryIsNull: migratedLegacyEntry === null,
migratedLegacyEntryHasPlaintextKey:
typeof (migratedLegacyEntry as any)?.privateKey === "string" &&
(migratedLegacyEntry as any).privateKey.length > 0,
legacyRawPlaintextSurvives,
actorMatch,
actorMismatch,
nsecActor,
@@ -541,7 +558,8 @@ test.describe("nostr runtime coverage (deterministic)", () => {
expect(result.wrongPassphraseErrorCode).toBe("decrypt-failed");
expect(result.metadataValid).toBeTruthy();
expect(result.metadataInvalidIsNull).toBe(true);
expect(result.migratedLegacyEntryIsNull).toBe(true);
expect(result.migratedLegacyEntryHasPlaintextKey).toBe(false);
expect(result.legacyRawPlaintextSurvives).toBe(false);
expect(result.actorMatch).toBe(true);
expect(result.actorMismatch).toBe(false);
expect(result.nsecActor).toBe(false);
+10 -2
View File
@@ -211,8 +211,16 @@ test.describe("component modal pages", () => {
waitUntil: "networkidle",
});
const modal = page.locator(".bv-modal");
await expect(modal).toHaveCount(1);
// A component page may legitimately ship more than one modal root:
// profile-modal.html also bundles #storageCorsModal, the B2/S3 CORS setup
// helper added in d7ad1516. Asserting exactly one was incidental to what
// this test is actually for -- "the page renders a modal that opens and
// closes" -- so require at least one and drive the primary one, which is
// what the injected script's document.querySelector(".bv-modal") already
// operates on.
const modals = page.locator(".bv-modal");
expect(await modals.count()).toBeGreaterThan(0);
const modal = modals.first();
// Inject logic to initialize the modal since we are viewing a partial/component in isolation.
await page.addScriptTag({
+29 -3
View File
@@ -29,6 +29,31 @@ test.describe("Webseed Stream Playback", () => {
});
test("plays directly from HTML link (CDN mode)", async ({ page }) => {
// Record every src the player attaches. Asserting the FINAL src is wrong:
// this test already tolerates "No playable source found" in its status
// check (headless CI often cannot decode the archive.org H.264 sample), and
// when the attempt fails resetVideoElement() clears src back to "". The
// behaviour under test is that CDN mode ATTACHES the hosted URL, which is
// observable regardless of whether the remote file then decodes.
await page.evaluate(() => {
(window as any).__attachedSrcs = [];
new MutationObserver((mutations) => {
for (const mutation of mutations) {
const target = mutation.target as HTMLElement;
if (mutation.attributeName === "src" && target.tagName === "VIDEO") {
const value = target.getAttribute("src");
if (value) {
(window as any).__attachedSrcs.push(value);
}
}
}
}).observe(document.body, {
subtree: true,
attributes: true,
attributeFilter: ["src"],
});
});
// 1. Trigger playback with just the URL
await page.evaluate(async (url) => {
const { getApplication } = await import("/js/applicationContext.js");
@@ -55,9 +80,10 @@ test.describe("Webseed Stream Playback", () => {
const urlToggle = page.locator('[data-source-toggle="url"]');
await expect(urlToggle).toHaveAttribute("aria-pressed", "true");
// Check if video is attempting to play from source
const video = page.locator("#modalVideo");
await expect(video).toHaveAttribute("src", VIDEO_URL);
// Check the player attached the hosted URL (see the observer above).
await expect
.poll(async () => await page.evaluate(() => (window as any).__attachedSrcs))
.toContain(VIDEO_URL);
});
test("falls back to WebTorrent using HTML link as webseed when CDN fails", async ({ page }) => {
+187
View File
@@ -0,0 +1,187 @@
// Addressable pointers to a video's NIP-71 mirror — see js/nostr/mirrorPointer.js.
//
// SCN-mirror-quote: a share note embeds `nostr:naddr1…` so nostr clients render
// a native video quote card. The pointer MUST address the event that actually
// exists: a pointer to a missing or wrong-kind event renders as a broken/empty
// quote box, which is strictly worse than the plain-link fallback.
import test from "node:test";
import assert from "node:assert/strict";
import {
NIP71_NORMAL_VIDEO_KIND,
NIP71_SHORT_VIDEO_KIND,
buildMirrorCoordinate,
buildMirrorNaddr,
resolveMirrorKind,
} from "../js/nostr/mirrorPointer.js";
const PUBKEY = "b7c6f6915cfa9a62fff6a1f02604de88c23c6c6c6d1b8f62c7cc10749f307e81";
const ROOT = "b52ab495-ac9c-4a29-a454-aff142a43691";
// Records what it was asked to encode so the tests can assert on inputs rather
// than on bech32 output.
const makeNip19 = () => {
const calls = [];
return {
calls,
naddrEncode(pointer) {
calls.push(pointer);
return `naddr1${pointer.kind}${pointer.identifier}`;
},
};
};
// --- kind resolution ------------------------------------------------------
test("observed relay kinds win over the dimension heuristic", () => {
// Ground truth from nip71MirrorService.findMirror must beat any guess —
// a portrait video whose mirror was published as 34235 is still 34235.
assert.equal(
resolveMirrorKind({ kinds: [NIP71_NORMAL_VIDEO_KIND], width: 720, height: 1280 }),
NIP71_NORMAL_VIDEO_KIND
);
assert.equal(
resolveMirrorKind({ kinds: [NIP71_SHORT_VIDEO_KIND], width: 1920, height: 1080 }),
NIP71_SHORT_VIDEO_KIND
);
});
test("without observed kinds it mirrors nip71Mirror.js's portrait rule", () => {
// nip71Mirror.js:179 — `hasDims && height > width` selects the short kind.
assert.equal(
resolveMirrorKind({ width: 720, height: 1280 }),
NIP71_SHORT_VIDEO_KIND
);
assert.equal(
resolveMirrorKind({ width: 1280, height: 720 }),
NIP71_NORMAL_VIDEO_KIND
);
// Square is not portrait.
assert.equal(
resolveMirrorKind({ width: 1000, height: 1000 }),
NIP71_NORMAL_VIDEO_KIND
);
});
test("missing or nonsensical dimensions default to the normal kind", () => {
for (const dims of [
{},
{ width: 0, height: 0 },
{ width: NaN, height: 10 },
{ width: "wide", height: "tall" },
{ width: -100, height: -200 },
]) {
assert.equal(resolveMirrorKind(dims), NIP71_NORMAL_VIDEO_KIND);
}
assert.equal(resolveMirrorKind(), NIP71_NORMAL_VIDEO_KIND);
});
test("a cross-kind duplicate resolves deterministically to the normal kind", () => {
// nip71MirrorService flags this as `duplicate`. Picking arbitrarily would
// make the shared pointer unstable between shares of the same video.
assert.equal(
resolveMirrorKind({ kinds: [NIP71_SHORT_VIDEO_KIND, NIP71_NORMAL_VIDEO_KIND] }),
NIP71_NORMAL_VIDEO_KIND
);
});
test("unknown kinds are ignored rather than pointed at", () => {
assert.equal(
resolveMirrorKind({ kinds: [1, 30078], width: 720, height: 1280 }),
NIP71_SHORT_VIDEO_KIND,
"falls through to the heuristic instead of addressing kind 1"
);
});
// --- coordinate -----------------------------------------------------------
test("buildMirrorCoordinate produces a kind:pubkey:d address", () => {
assert.equal(
buildMirrorCoordinate({ pubkey: PUBKEY, videoRootId: ROOT, kind: 34235 }),
`34235:${PUBKEY}:${ROOT}`
);
});
test("buildMirrorCoordinate refuses incomplete input", () => {
assert.equal(buildMirrorCoordinate({ videoRootId: ROOT, kind: 34235 }), "");
assert.equal(buildMirrorCoordinate({ pubkey: PUBKEY, kind: 34235 }), "");
assert.equal(buildMirrorCoordinate({ pubkey: PUBKEY, videoRootId: ROOT }), "");
assert.equal(buildMirrorCoordinate({ pubkey: PUBKEY, videoRootId: ROOT, kind: 0 }), "");
assert.equal(buildMirrorCoordinate(), "");
});
// --- naddr ----------------------------------------------------------------
test("buildMirrorNaddr encodes kind, author, identifier and relay hints", () => {
const nip19 = makeNip19();
const naddr = buildMirrorNaddr({
pubkey: PUBKEY,
videoRootId: ROOT,
kind: NIP71_NORMAL_VIDEO_KIND,
relays: ["wss://relay.damus.io", "wss://nos.lol"],
nip19,
});
assert.equal(naddr.startsWith("naddr1"), true);
assert.deepEqual(nip19.calls[0], {
kind: NIP71_NORMAL_VIDEO_KIND,
pubkey: PUBKEY,
identifier: ROOT,
relays: ["wss://relay.damus.io", "wss://nos.lol"],
});
});
test("relay hints are filtered to wss/ws and capped at three", () => {
const nip19 = makeNip19();
buildMirrorNaddr({
pubkey: PUBKEY,
videoRootId: ROOT,
kind: 34235,
relays: [
"https://not-a-relay.example",
"wss://a.example",
"",
"wss://b.example",
"ws://c.example",
"wss://d.example",
null,
],
nip19,
});
assert.deepEqual(nip19.calls[0].relays, [
"wss://a.example",
"wss://b.example",
"ws://c.example",
]);
});
test("buildMirrorNaddr returns empty rather than a malformed pointer", () => {
const nip19 = makeNip19();
const base = { pubkey: PUBKEY, videoRootId: ROOT, kind: 34235, nip19 };
assert.equal(buildMirrorNaddr({ ...base, pubkey: "" }), "");
assert.equal(buildMirrorNaddr({ ...base, videoRootId: "" }), "");
assert.equal(buildMirrorNaddr({ ...base, kind: 0 }), "");
assert.equal(buildMirrorNaddr({ ...base, nip19: null }), "");
assert.equal(buildMirrorNaddr({ ...base, nip19: {} }), "");
assert.equal(buildMirrorNaddr(), "");
assert.equal(nip19.calls.length, 0, "never attempted an encode");
});
test("an encoder that throws yields empty, never a broken quote", () => {
const hostile = {
naddrEncode() {
throw new Error("bad pointer");
},
};
assert.equal(
buildMirrorNaddr({
pubkey: PUBKEY,
videoRootId: ROOT,
kind: 34235,
nip19: hostile,
}),
""
);
});
+20 -6
View File
@@ -92,18 +92,32 @@ test("trusted report summaries respect personal blocks and admin lists", async (
service.recomputeSummaryForEvent(eventId);
// 18e08bf2 ("refine trust controls") removed `status.whitelisted ||` from
// isTrustedReporter and updated the doc comment from "trustedContacts set (or
// Admin Whitelist)" to "trustedContacts set". Being on the admin whitelist is
// permission to publish, not a statement that your reports carry weight, so a
// whitelisted reporter now counts toward the report TOTAL but not toward
// TRUSTED.
//
// Counted reporters: trusted (t=100), whitelisted (t=400), random (t=300).
// Excluded entirely: blacklisted (admin list) and blocked (personal block).
const summary = service.getTrustedReportSummary(eventId);
assert.equal(summary.totalTrusted, 2);
assert.equal(summary.types.nudity.total, 3);
assert.equal(summary.types.nudity.trusted, 2);
assert.equal(summary.types.nudity.latest, 400);
assert.equal(summary.totalTrusted, 1, "only the trusted contact is trusted");
assert.equal(summary.types.nudity.total, 3, "whitelisted still counts as a report");
assert.equal(summary.types.nudity.trusted, 1);
assert.equal(
summary.types.nudity.latest,
400,
"latest still reflects the whitelisted reporter's timestamp"
);
const reporters = service.getTrustedReporters(eventId, "nudity");
assert.deepEqual(
reporters.map((entry) => entry.pubkey),
[whitelistedHex, trustedHex]
[trustedHex],
"the whitelisted reporter is no longer surfaced as a trusted reporter"
);
assert.equal(service.trustedReportCount(eventId, "nudity"), 2);
assert.equal(service.trustedReportCount(eventId, "nudity"), 1);
});
test("user block updates recompute summaries and emit notifications", async (t) => {
+212
View File
@@ -0,0 +1,212 @@
// SignerManager lifecycle scoping for NIP-46 multi-account (pre-launch TODO #33).
//
// The 2026-06-25 audit blamed "logout logs out everyone" on the single shared
// nip46Client being torn down by any scoped logout. Since then the layers under
// it were fixed piecemeal (per-account v2 session map, scoped clears, targeted
// restore) — but nothing pinned the MANAGER-level contract, so any of those
// fixes could regress silently. These tests exercise the real SignerManager
// against the real signer registry and real (polyfilled) storage.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-nip46-logout-scope
// given: "account A active with a live remote-signer client; A and B both have stored sessions; B has a registered signer"
// when: "SignerManager.logout() logs out A"
// then: "A's live client is destroyed exactly once; BOTH stored sessions survive; B's registry signer survives"
// - id: SCN-nip46-restore-failure-scope
// given: "stored sessions for A and B"
// when: "a targeted restore of A fails (locked session / missing session)"
// then: "the failure is typed and NO stored session is deleted"
// observable_outcomes:
// - "fake client's destroy() call count"
// - "readStoredNip46Session(pubkey) after the operation"
// - "resolveActiveSigner(pubkey) for the OTHER account"
// determinism_controls:
// - "in-memory localStorage polyfill; fake in-process client object; no network"
// anti_cheat_rationale:
// prevents: ["over-mocking internal logic", "hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test, { beforeEach } from "node:test";
import { strict as assert } from "node:assert";
import { SignerManager } from "../js/nostr/managers/SignerManager.js";
import {
writeStoredNip46SessionSync,
readStoredNip46Session,
} from "../js/nostr/nip46Client.js";
import {
registerSigner,
setActiveSigner,
clearActiveSigner,
resolveActiveSigner,
} from "../js/nostrClientRegistry.js";
const USER_A =
"00000000000000000000000000000000000000000000000000000000000000a1";
const USER_B =
"00000000000000000000000000000000000000000000000000000000000000b2";
function storedSession(userPubkey, remotePubkey) {
return {
version: 1,
clientPublicKey:
"3333333333333333333333333333333333333333333333333333333333333333",
remotePubkey,
relays: ["wss://relay.example"],
encryption: "nip44.v2",
userPubkey,
lastConnectedAt: 1700000000000,
// Sessions at rest carry encrypted secrets; restoring without the
// passphrase must fail typed, not fall through to a broken connect.
encryptedSecrets: "cipher",
keyEncryption: {
salt: "c2FsdA==",
iv: "aXY=",
iterations: 1000,
hash: "SHA-256",
},
};
}
function makeManager() {
// SignerManager only needs the app client for pool access on connect paths;
// the scoping contracts under test never reach the network.
return new SignerManager({
relays: ["wss://relay.example"],
ensurePool: async () => {
throw new Error("network must not be touched by these tests");
},
});
}
beforeEach(() => {
localStorage.clear();
clearActiveSigner();
});
test("logging out the active NIP-46 account keeps every other account intact", () => {
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
// B is a signed-in account in the registry (e.g. logged in earlier this
// session); A is the ACTIVE account with the live remote-signer client.
const signerB = { pubkey: USER_B, signEvent: async (e) => e };
registerSigner(USER_B, signerB);
let destroyCalls = 0;
const liveClient = {
remotePubkey: "1".repeat(64),
userPubkey: USER_A,
destroy() {
destroyCalls += 1;
},
};
const manager = makeManager();
manager.pubkey = USER_A;
manager.nip46Client = liveClient;
manager.logout();
// The departing account's LIVE connection is torn down exactly once…
assert.equal(destroyCalls, 1);
assert.equal(manager.nip46Client, null);
assert.equal(manager.pubkey, null);
// …but its stored session survives (logout keeps sessions reconnectable —
// that is what makes "switch back later" possible)…
const storedA = readStoredNip46Session(USER_A);
assert.ok(storedA, "A's stored session must survive A's own logout");
assert.equal(storedA.remotePubkey, "1".repeat(64));
// …and account B is completely untouched: stored session AND registry signer.
const storedB = readStoredNip46Session(USER_B);
assert.ok(storedB, "B's stored session must survive A's logout");
assert.equal(storedB.remotePubkey, "2".repeat(64));
assert.equal(
resolveActiveSigner(USER_B),
signerB,
"B's registered signer must survive A's logout"
);
});
test("logout is safe when no remote client is live (nsec/nip07 active)", () => {
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
const manager = makeManager();
manager.pubkey = USER_A; // active via some non-NIP-46 method
assert.doesNotThrow(() => manager.logout());
assert.ok(
readStoredNip46Session(USER_B),
"an unrelated account's NIP-46 session must survive a non-NIP-46 logout"
);
});
test("a failed targeted restore deletes nothing", async () => {
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
const manager = makeManager();
// A's session is passphrase-locked and none was provided: typed failure.
await assert.rejects(
manager.useStoredRemoteSigner({ pubkey: USER_A, silent: true }),
(error) => error?.code === "passphrase-required"
);
assert.ok(readStoredNip46Session(USER_A), "A's session survives its own failed restore");
assert.ok(readStoredNip46Session(USER_B), "B's session survives A's failed restore");
});
test("restoring an account with no stored session fails typed and touches nothing", async () => {
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
const manager = makeManager();
await assert.rejects(
manager.useStoredRemoteSigner({ pubkey: USER_A, silent: true }),
(error) => error?.code === "no-stored-session"
);
assert.ok(
readStoredNip46Session(USER_B),
"an unrelated stored session must survive a missing-session restore attempt"
);
});
test("disconnectRemoteSigner({keepStored:true}) never clears stored sessions", async () => {
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
const manager = makeManager();
manager.nip46Client = { destroy() {} };
await manager.disconnectRemoteSigner({ keepStored: true });
assert.ok(readStoredNip46Session(USER_A));
});
test("disconnectRemoteSigner() without keepStored clears only the last-connected default", async () => {
// The no-arg clear historically wiped the whole v2 map — "after switching
// away you could no longer switch back". Pin the scoped behavior: the v1
// default slot goes, the per-account map survives.
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
const manager = makeManager();
manager.nip46Client = { destroy() {} };
await manager.disconnectRemoteSigner();
assert.ok(
readStoredNip46Session(USER_A),
"per-account session A must survive a bare disconnect"
);
assert.ok(
readStoredNip46Session(USER_B),
"per-account session B must survive a bare disconnect"
);
});
+23
View File
@@ -20,6 +20,7 @@ function makeHarness({ whitelistOn = false, whitelist = [] } = {}) {
const allEvents = new Map();
let capturedFilters = null;
let onEvent = null;
let onEose = null;
let closed = false;
let whitelistListener = null;
@@ -27,6 +28,7 @@ function makeHarness({ whitelistOn = false, whitelist = [] } = {}) {
subscribe(opts) {
capturedFilters = opts.filters;
onEvent = opts.onEvent;
onEose = opts.onEose;
return {
close() {
closed = true;
@@ -81,6 +83,7 @@ function makeHarness({ whitelistOn = false, whitelist = [] } = {}) {
return closed;
},
fireEvent: (e) => onEvent?.(e),
triggerEose: () => onEose?.(),
triggerWhitelistChange: () => whitelistListener?.(),
triggerFeedReady: () => feedListeners.forEach((fn) => fn({})),
nostrClient,
@@ -263,6 +266,26 @@ test("startWhenFeedReady falls back to starting if the feed never emits", async
svc.stop();
});
test("initial NIP-71 sync keeps every event but emits one consolidated feed update", () => {
const h = makeHarness();
const svc = makeService(h, { feedReadyFallbackMs: 100000, initialSyncFallbackMs: 100000 });
svc.startWhenFeedReady();
h.triggerFeedReady();
h.fireEvent(foreignEvent({ id: "a", kind: 34235, tags: [["d", "ra"], ["title", "A"], ["imeta", "url https://e/a.mp4", "m video/mp4"]] }));
h.fireEvent(foreignEvent({ id: "b", kind: 34235, tags: [["d", "rb"], ["title", "B"], ["imeta", "url https://e/b.mp4", "m video/mp4"]] }));
svc.flush();
assert.ok(h.activeMap.has("ROOT:ra") && h.activeMap.has("ROOT:rb"), "all initial NIP-71 events are retained");
assert.equal(h.emitted.filter((e) => e.name === "videos:updated").length, 0, "no partial-startup repaint");
h.triggerEose();
const refreshes = h.emitted.filter((e) => e.name === "videos:updated");
assert.equal(refreshes.length, 1, "one consolidated update at initial sync completion");
assert.equal(refreshes[0].detail.reason, "nip71-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 });
+19 -4
View File
@@ -111,21 +111,36 @@ class FakeRelayPool {
}
async function configureNostrTools() {
const previousCanonical = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
const previousCanonicalDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
"__BITVID_CANONICAL_NOSTR_TOOLS__",
);
const previousNostrTools = globalThis.NostrTools;
const previousReady = globalThis.nostrToolsReady;
const nostrTools = await import("nostr-tools");
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = nostrTools;
// nostrToolsBootstrap deliberately exposes this value as read-only. Test
// setup must replace its *configurable descriptor*, rather than assigning to
// the property and failing after another test/import initialized bootstrap.
Object.defineProperty(globalThis, "__BITVID_CANONICAL_NOSTR_TOOLS__", {
configurable: true,
enumerable: false,
writable: true,
value: nostrTools,
});
globalThis.NostrTools = nostrTools;
globalThis.nostrToolsReady = Promise.resolve({ ok: true, value: nostrTools });
const restore = () => {
if (previousCanonical === undefined) {
if (!previousCanonicalDescriptor) {
delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
} else {
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical;
Object.defineProperty(
globalThis,
"__BITVID_CANONICAL_NOSTR_TOOLS__",
previousCanonicalDescriptor,
);
}
if (previousNostrTools === undefined) {
+426
View File
@@ -0,0 +1,426 @@
// Per-video OpenGraph rendering for crawlers — see api/_lib/ogRenderer.mjs.
//
// Two things here are security-relevant and get the most attention:
// 1. Escaping. Titles/descriptions come from arbitrary nostr events, and this
// HTML is served from bitvid's own origin.
// 2. Gating. A crawler never authenticates, so anything emitted is public
// forever in third-party caches. Deleted/private/NSFW/non-whitelisted
// videos must fall back to the generic site card.
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import {
CRAWLER_USER_AGENT_PATTERN,
escapeHtmlAttribute,
isCrawlerUserAgent,
isPubliclyShareable,
normalizeText,
parseVideoEvent,
renderOgHtml,
sanitizeAbsoluteUrl,
} from "../api/_lib/ogRenderer.mjs";
const PUBKEY = "b7c6f6915cfa9a62fff6a1f02604de88c23c6c6c6d1b8f62c7cc10749f307e81";
const publicVideo = () => ({
pubkey: PUBKEY,
title: "Why I'm (sort of) not worried about AI",
description: "A talk about creativity and machines.",
thumbnail: "https://cdn.example/thumb.jpg",
deleted: false,
isPrivate: false,
isNsfw: false,
invalid: false,
});
const FALLBACK = {
siteName: "bitvid",
title: "bitvid - Decentralized Video Sharing",
description: "Decentralized video sharing on Nostr.",
image: "https://bitvid.network/assets/jpg/bitvid.jpg",
url: "https://bitvid.network",
};
const render = (video, extra = {}) =>
renderOgHtml({
video,
shareUrl: "https://bitvid.network/?v=nevent1abc",
embedUrl: "https://bitvid.network/embed.html?pointer=nevent1abc",
fallback: FALLBACK,
gate: isPubliclyShareable(video, extra.gateOptions || {}),
...extra.render,
});
// --- escaping -------------------------------------------------------------
test("escapeHtmlAttribute neutralizes attribute-breaking characters", () => {
assert.equal(
escapeHtmlAttribute(`"><script>alert(1)</script>`),
"&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;"
);
assert.equal(escapeHtmlAttribute("a & b"), "a &amp; b");
assert.equal(escapeHtmlAttribute("it's"), "it&#39;s");
assert.equal(escapeHtmlAttribute(null), "");
assert.equal(escapeHtmlAttribute(42), "");
});
test("a hostile title cannot break out of the meta tag", () => {
const video = publicVideo();
video.title = `"><script>alert(document.domain)</script><meta x="`;
const html = render(video);
assert.equal(html.includes("<script>"), false, "no raw script tag");
assert.equal(html.includes(`"><script`), false, "no attribute breakout");
assert.match(html, /&lt;script&gt;/);
});
test("a hostile description is escaped too", () => {
const video = publicVideo();
video.description = `" onload="alert(1)`;
const html = render(video);
assert.equal(html.includes(`onload="alert(1)`), false);
assert.match(html, /&quot; onload=&quot;alert\(1\)/);
});
// --- URL sanitization -----------------------------------------------------
test("sanitizeAbsoluteUrl accepts only http(s)", () => {
assert.equal(
sanitizeAbsoluteUrl("https://cdn.example/a.jpg"),
"https://cdn.example/a.jpg"
);
assert.equal(sanitizeAbsoluteUrl("http://cdn.example/a.jpg"), "http://cdn.example/a.jpg");
assert.equal(sanitizeAbsoluteUrl("javascript:alert(1)"), "");
assert.equal(sanitizeAbsoluteUrl("data:image/png;base64,AAAA"), "");
assert.equal(sanitizeAbsoluteUrl("assets/jpg/local.jpg"), "");
assert.equal(sanitizeAbsoluteUrl(""), "");
assert.equal(sanitizeAbsoluteUrl(null), "");
});
test("a javascript: thumbnail never reaches og:image", () => {
const video = publicVideo();
video.thumbnail = "javascript:alert(1)";
const html = render(video);
assert.equal(html.includes("javascript:"), false);
// Falls back to the site image rather than emitting nothing.
assert.match(html, /og:image" content="https:\/\/bitvid\.network\/assets/);
});
// --- text normalization ---------------------------------------------------
test("normalizeText collapses whitespace and truncates with an ellipsis", () => {
assert.equal(normalizeText(" a \n b \t c ", 50), "a b c");
const long = "x".repeat(300);
const out = normalizeText(long, 120);
assert.equal(out.length, 120);
assert.equal(out.endsWith("…"), true);
assert.equal(normalizeText(null, 10), "");
});
// --- gating ---------------------------------------------------------------
test("a clean public video is shareable", () => {
assert.deepEqual(isPubliclyShareable(publicVideo()), {
allowed: true,
reason: "ok",
});
});
test("deleted, private, NSFW and invalid videos are never described", () => {
for (const [field, reason] of [
["deleted", "deleted"],
["isPrivate", "private"],
["isNsfw", "nsfw"],
["invalid", "invalid-video"],
]) {
const video = publicVideo();
video[field] = true;
const result = isPubliclyShareable(video);
assert.equal(result.allowed, false, `${field} must gate the preview`);
assert.equal(result.reason, reason);
}
});
test("a missing video or author is gated", () => {
assert.equal(isPubliclyShareable(null).allowed, false);
assert.equal(isPubliclyShareable(undefined).reason, "missing-video");
const noAuthor = publicVideo();
noAuthor.pubkey = "";
assert.equal(isPubliclyShareable(noAuthor).reason, "missing-author");
});
test("a blacklisted author is gated regardless of whitelist mode", () => {
const result = isPubliclyShareable(publicVideo(), {
blacklist: new Set([PUBKEY]),
});
assert.equal(result.allowed, false);
assert.equal(result.reason, "blacklisted");
});
test("whitelist mode fails CLOSED when the list is missing or empty", () => {
// The dangerous case: relays timed out, so we cannot prove the author is
// allowed. Emitting a rich card here would leak gated metadata.
assert.equal(
isPubliclyShareable(publicVideo(), { whitelistEnabled: true }).reason,
"whitelist-unavailable"
);
assert.equal(
isPubliclyShareable(publicVideo(), {
whitelistEnabled: true,
whitelist: new Set(),
}).reason,
"whitelist-unavailable"
);
});
test("whitelist mode admits a listed author and rejects an unlisted one", () => {
assert.equal(
isPubliclyShareable(publicVideo(), {
whitelistEnabled: true,
whitelist: new Set([PUBKEY]),
}).allowed,
true
);
assert.equal(
isPubliclyShareable(publicVideo(), {
whitelistEnabled: true,
whitelist: new Set(["a".repeat(64)]),
}).reason,
"not-whitelisted"
);
});
test("gated videos render the generic site card, leaking no metadata", () => {
const video = publicVideo();
video.isPrivate = true;
video.title = "Secret internal recording";
video.thumbnail = "https://cdn.example/secret.jpg";
const html = render(video);
assert.equal(html.includes("Secret internal recording"), false);
assert.equal(html.includes("secret.jpg"), false);
assert.match(html, /og:title" content="bitvid - Decentralized Video Sharing"/);
assert.match(html, /og:type" content="website"/);
// No player card for a gated video.
assert.equal(html.includes("twitter:player"), false);
});
// --- rendering ------------------------------------------------------------
test("a public video renders a player card pointing at the embed", () => {
const html = render(publicVideo());
assert.match(html, /og:type" content="video\.other"/);
assert.match(html, /og:title" content="Why I&#39;m \(sort of\) not worried about AI"/);
assert.match(html, /og:image" content="https:\/\/cdn\.example\/thumb\.jpg"/);
assert.match(
html,
/og:video:url" content="https:\/\/bitvid\.network\/embed\.html\?pointer=nevent1abc"/
);
assert.match(html, /twitter:card" content="player"/);
assert.match(
html,
/twitter:player" content="https:\/\/bitvid\.network\/embed\.html\?pointer=nevent1abc"/
);
assert.match(html, /twitter:player:width" content="1280"/);
assert.match(html, /twitter:player:height" content="720"/);
assert.match(html, /<link rel="canonical" href="https:\/\/bitvid\.network\/\?v=nevent1abc"/);
});
test("without an embed URL it degrades to a summary_large_image card", () => {
const html = renderOgHtml({
video: publicVideo(),
shareUrl: "https://bitvid.network/?v=nevent1abc",
fallback: FALLBACK,
gate: { allowed: true, reason: "ok" },
});
assert.match(html, /twitter:card" content="summary_large_image"/);
assert.equal(html.includes("twitter:player"), false);
});
test("the rendered document is well-formed and self-contained", () => {
const html = render(publicVideo());
assert.match(html, /^<!doctype html>/);
assert.match(html, /<\/html>\n$/);
// Crawler stub only — it must not pull in the app or any script.
assert.equal(/<script/i.test(html), false);
});
// --- event parsing --------------------------------------------------------
// bitvid publishes kind 30078 with a v3 JSON body; nostube/slidestr and other
// NIP-71-native clients publish kind 21/22/34235/34236 with the metadata in
// TAGS. Parsing only the JSON shape made every NIP-71 video — including the
// real note this work started from — fall back to the generic card.
test("parseVideoEvent reads the bitvid v3 JSON shape", () => {
const parsed = parseVideoEvent({
kind: 30078,
pubkey: PUBKEY,
content: JSON.stringify({
version: 3,
title: "A bitvid upload",
description: "Body text",
thumbnail: "https://cdn.example/t.jpg",
url: "https://cdn.example/v.mp4",
isNsfw: false,
}),
tags: [],
});
assert.equal(parsed.title, "A bitvid upload");
assert.equal(parsed.description, "Body text");
assert.equal(parsed.thumbnail, "https://cdn.example/t.jpg");
assert.equal(parsed.invalid, false);
assert.equal(isPubliclyShareable(parsed).allowed, true);
});
test("parseVideoEvent reads the NIP-71 tag shape (the real nostube note)", () => {
const parsed = parseVideoEvent({
kind: 34235,
pubkey: PUBKEY,
content: "As a creator, and as a leader of a tech company…",
tags: [
["d", "b52ab495-ac9c-4a29-a454-aff142a43691"],
["title", "Why I'm (sort of) not worried about AI"],
[
"imeta",
"dim 1280x720",
"url https://almond.example/video.m3u8",
"m application/vnd.apple.mpegurl",
"image https://almond.example/thumb.jpg",
"image https://blossom.example/thumb.jpg",
],
["client", "nostube"],
],
});
assert.equal(parsed.title, "Why I'm (sort of) not worried about AI");
assert.equal(parsed.thumbnail, "https://almond.example/thumb.jpg");
assert.match(parsed.description, /^As a creator/);
assert.equal(parsed.invalid, false);
});
test("parseVideoEvent treats a content-warning tag as NSFW", () => {
const parsed = parseVideoEvent({
kind: 34235,
pubkey: PUBKEY,
content: "desc",
tags: [["title", "Sensitive"], ["content-warning", "nudity"]],
});
assert.equal(parsed.isNsfw, true);
assert.equal(isPubliclyShareable(parsed).reason, "nsfw");
});
test("parseVideoEvent rejects untitled and non-video events", () => {
assert.equal(
parseVideoEvent({ kind: 34235, pubkey: PUBKEY, content: "", tags: [] }).invalid,
true
);
// A plain kind-1 note is not a video.
assert.equal(parseVideoEvent({ kind: 1, pubkey: PUBKEY, content: "hi", tags: [] }), null);
assert.equal(parseVideoEvent(null), null);
assert.equal(parseVideoEvent({}), null);
});
test("parseVideoEvent survives malformed JSON content", () => {
// A kind-30078 whose content is truncated/corrupt must not throw.
assert.equal(
parseVideoEvent({ kind: 30078, pubkey: PUBKEY, content: "{not json", tags: [] }),
null
);
});
test("parseVideoEvent ignores non-http imeta image entries", () => {
const parsed = parseVideoEvent({
kind: 34235,
pubkey: PUBKEY,
content: "desc",
tags: [
["title", "T"],
["imeta", "image javascript:alert(1)", "image https://ok.example/t.png"],
],
});
assert.equal(parsed.thumbnail, "https://ok.example/t.png");
});
// --- crawler detection ----------------------------------------------------
test("known preview crawlers are recognized", () => {
for (const ua of [
"facebookexternalhit/1.1",
"Twitterbot/1.0",
"Slackbot-LinkExpanding 1.0",
"Mozilla/5.0 (compatible; Discordbot/2.0)",
"TelegramBot (like TwitterBot)",
"WhatsApp/2.19",
"Mozilla/5.0 (compatible; Googlebot/2.1)",
]) {
assert.equal(isCrawlerUserAgent(ua), true, `${ua} should match`);
}
});
test("real browsers are NOT treated as crawlers", () => {
for (const ua of [
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15",
"",
]) {
assert.equal(isCrawlerUserAgent(ua), false, `${ua} must not match`);
}
assert.equal(isCrawlerUserAgent(null), false);
assert.equal(isCrawlerUserAgent(undefined), false);
});
test("the crawler pattern is not sticky/global (no lastIndex state)", () => {
// A /g regex would alternate true/false across calls via lastIndex — a
// classic bug that would randomly serve crawlers the wrong page.
assert.equal(CRAWLER_USER_AGENT_PATTERN.global, false);
const ua = "Twitterbot/1.0";
assert.equal(isCrawlerUserAgent(ua), true);
assert.equal(isCrawlerUserAgent(ua), true);
assert.equal(isCrawlerUserAgent(ua), true);
});
// --- config drift ---------------------------------------------------------
// api/og.js imports the admin-list identity and relay set from the SAME modules
// the browser uses, rather than keeping its own copies. These assertions fail
// loudly if that ever regresses to duplicated constants, because the failure
// mode otherwise is silent: previews gated forever, or a stale whitelist.
test("api/og.js derives its config from the app, not from copies", async () => {
const source = await readFile(new URL("../api/og.js", import.meta.url), "utf8");
assert.match(source, /from "\.\.\/js\/config\.js"/);
assert.match(source, /from "\.\.\/js\/nostr\/toolkit\.js"/);
assert.match(source, /from "\.\.\/js\/nostrEventSchemas\.js"/);
// No hardcoded npub or wss:// literal may reappear in the handler.
assert.equal(
/npub1[0-9a-z]{20,}/.test(source),
false,
"admin npub must come from js/config.js, not a literal"
);
assert.equal(
/"wss:\/\//.test(source),
false,
"relay URLs must come from js/nostr/toolkit.js, not literals"
);
});
test("the admin list d-tag matches what the browser store queries", async () => {
const { ADMIN_LIST_NAMESPACE } = await import("../js/config.js");
const { ADMIN_LIST_IDENTIFIERS } = await import("../js/nostrEventSchemas.js");
// Pinned against the live list this instance actually publishes; a namespace
// change without a matching relay migration would silently gate everything.
assert.equal(
`${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.whitelist}`,
"bitvid:admin:whitelist"
);
assert.equal(
`${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.blacklist}`,
"bitvid:admin:blacklist"
);
});
+91
View File
@@ -0,0 +1,91 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
initPerformanceHarness,
isLocalPerformanceHarnessEnabled,
PERFORMANCE_HARNESS_STORAGE_KEY,
} from "../js/performanceHarness.js";
function createStorage(value = null) {
return { getItem: (key) => (key === PERFORMANCE_HARNESS_STORAGE_KEY ? value : null) };
}
function createPerformance() {
const marks = [];
return {
now: () => 100,
mark: (name) => marks.push(name),
getEntriesByType: (type) => {
if (type === "resource") {
return [{ transferSize: 12 }, { transferSize: 30 }];
}
if (type === "navigation") {
return [{ domContentLoadedEventEnd: 12, loadEventEnd: 24 }];
}
return [];
},
memory: { usedJSHeapSize: 10, totalJSHeapSize: 20, jsHeapSizeLimit: 30 },
marks,
};
}
test("performance harness stays absent without explicit loopback opt-in", () => {
const scope = {
location: { hostname: "localhost" },
localStorage: createStorage(),
performance: createPerformance(),
};
assert.equal(isLocalPerformanceHarnessEnabled({
locationRef: scope.location,
storage: scope.localStorage,
}), false);
assert.equal(initPerformanceHarness({ scope }), null);
assert.equal(scope.__bitvidPerformance, undefined);
});
test("performance harness cannot be enabled on a non-loopback host", () => {
assert.equal(isLocalPerformanceHarnessEnabled({
locationRef: { hostname: "unstable.bitvid.network" },
storage: createStorage("1"),
}), false);
});
test("explicit loopback opt-in exposes bounded local measurements", () => {
let observerCallback = null;
class FakePerformanceObserver {
constructor(callback) {
observerCallback = callback;
}
observe() {}
disconnect() {}
}
const scope = {
location: { hostname: "127.0.0.1" },
localStorage: createStorage("1"),
performance: createPerformance(),
PerformanceObserver: FakePerformanceObserver,
};
const api = initPerformanceHarness({ scope });
assert.equal(typeof api?.snapshot, "function");
assert.equal(scope.__bitvidPerformance, api);
observerCallback({
getEntries: () => [{ name: "longtask", startTime: 3, duration: 51 }],
});
api.mark("feed-refresh", { feed: "for-you" });
api.setGauge("active-workers", 1);
api.increment("active-workers");
const snapshot = api.snapshot();
assert.equal(snapshot.longTasks.count, 1);
assert.equal(snapshot.longTasks.totalDurationMs, 51);
assert.equal(snapshot.resources.transferSize, 42);
assert.equal(snapshot.gauges["active-workers"], 2);
assert.equal(snapshot.memory.usedJsHeapSize, 10);
assert.ok(snapshot.marks.some((entry) => entry.name === "feed-refresh"));
api.stop();
assert.equal(scope.__bitvidPerformance, undefined);
});
@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ProfilePlaylistsController } from "../js/ui/profileModal/ProfilePlaylistsController.js";
test("playlist refresh renders the list before deferred video metadata completes", async () => {
const controller = new ProfilePlaylistsController({
getActivePubkey: () => "a".repeat(64),
services: {
nostrService: {
fetchVideosByAuthors: () => new Promise(() => {}),
},
},
});
const renders = [];
let resolveMetadata;
controller.listEl = {};
controller.setLoading = () => {};
controller.render = () => renders.push(controller.playlists.map((playlist) => playlist.id));
controller.fetchCreatorPlaylists = async () => [{
id: "existing",
items: [{ type: "a", value: `30078:${"b".repeat(64)}:video` }],
}];
controller.mainController.services.nostrService.fetchVideosByAuthors = () =>
new Promise((resolve) => {
resolveMetadata = resolve;
});
await controller.refresh();
assert.deepEqual(renders, [["existing"]], "list renders before metadata request settles");
resolveMetadata();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(renders, [["existing"], ["existing"]], "metadata completion refreshes labels later");
});
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import ExploreDataService from "../../js/services/exploreDataService.js";
test("ExploreDataService stays idle until a recommendation feed activates it", async () => {
const service = new ExploreDataService({
historyRefreshIntervalMs: 0,
idfRefreshIntervalMs: 0,
});
let historyRefreshes = 0;
let idfRefreshes = 0;
service.refreshWatchHistoryTagCounts = async () => {
historyRefreshes += 1;
return new Map();
};
service.refreshTagIdf = async () => {
idfRefreshes += 1;
return new Map();
};
await service.initialize();
assert.equal(historyRefreshes, 0);
assert.equal(idfRefreshes, 0);
service.queueWatchHistoryRefresh("note");
service.queueTagIdfRefresh("note");
await service.setActive(true);
assert.equal(historyRefreshes, 1, "dirty history is refreshed on activation");
assert.equal(idfRefreshes, 1, "dirty IDF is refreshed on activation");
await service.setActive(false);
service.queueTagIdfRefresh("inactive-note");
assert.equal(idfRefreshes, 1, "inactive feeds do not rebuild recommendation indexes");
service.destroy();
});
test("ExploreDataService coalesces a refresh burst into one running pass and one follow-up", async () => {
const service = new ExploreDataService({
historyRefreshIntervalMs: 0,
idfRefreshIntervalMs: 0,
});
service.active = true;
let runs = 0;
let releaseFirst;
service._refreshTagIdf = async () => {
runs += 1;
if (runs === 1) {
await new Promise((resolve) => {
releaseFirst = resolve;
});
}
return new Map();
};
const first = service.refreshTagIdf({ force: true, reason: "initial" });
const duplicate = service.refreshTagIdf({ reason: "relay-event" });
service.refreshTagIdf({ reason: "timer" });
assert.equal(runs, 1, "only one rebuild starts during the burst");
releaseFirst();
await Promise.all([first, duplicate]);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(runs, 2, "the burst produces exactly one follow-up rebuild");
service.destroy();
});
+632
View File
@@ -0,0 +1,632 @@
// HLS (.m3u8) support — see js/services/hlsPlayback.js.
//
// Scenario spec (SCN-hls-*): bitvid used to attach every hosted source with
// `videoEl.src = url`, which only plays HLS on Safari. Nostr clients publishing
// bitvid's v3 schema with HLS ladders (nostube/slidestr) were unplayable on
// Chrome/Firefox, and the URL-health probe reported every healthy HLS stream as
// offline. These tests pin the observable decisions at those boundaries:
// detection, engine selection, teardown, and the manifest probe.
//
// The hls.js engine itself is never loaded here — the tests exercise the
// native-HLS branch and the fetch-based manifest probe, which are the two paths
// that do not require a real MediaSource implementation.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
HLS_MIME_TYPES,
attachHlsSource,
detachHls,
getAttachedHls,
isHlsMimeType,
isHlsSource,
isHlsUrl,
prefersNativeHls,
probeHlsManifest,
supportsMse,
supportsNativeHls,
} from "../../js/services/hlsPlayback.js";
// --- SCN-hls-detect -------------------------------------------------------
// Given a hosted URL, when its path ends in .m3u8, then it is treated as HLS —
// regardless of query strings (signed CDN URLs) or fragments.
test("isHlsUrl matches .m3u8 paths, including with query/fragment", () => {
assert.equal(
isHlsUrl(
"https://almond.apps2.slidestr.net/3751b84f27234fc8ce3227d132b422f7e00f4a50c6cdc322080fed89a302d7dd.m3u8"
),
true
);
assert.equal(isHlsUrl("https://cdn.example/v.M3U8"), true);
assert.equal(isHlsUrl("https://cdn.example/v.m3u8?token=abc&exp=1"), true);
assert.equal(isHlsUrl("https://cdn.example/v.m3u8#t=10"), true);
assert.equal(isHlsUrl(" https://cdn.example/v.m3u8 "), true);
});
test("isHlsUrl rejects non-playlist URLs and incidental .m3u8 substrings", () => {
assert.equal(isHlsUrl("https://cdn.example/v.mp4"), false);
assert.equal(isHlsUrl("https://cdn.example/v.webm"), false);
// .m3u8 must terminate the PATH, not merely appear somewhere in the URL.
assert.equal(isHlsUrl("https://cdn.example/v.m3u8.mp4"), false);
assert.equal(isHlsUrl("https://cdn.example/?next=/a.m3u8"), false);
assert.equal(isHlsUrl(""), false);
assert.equal(isHlsUrl(null), false);
assert.equal(isHlsUrl(undefined), false);
assert.equal(isHlsUrl(42), false);
});
test("isHlsMimeType covers the canonical and legacy playlist types", () => {
for (const type of HLS_MIME_TYPES) {
assert.equal(isHlsMimeType(type), true, `${type} should be HLS`);
assert.equal(isHlsMimeType(type.toUpperCase()), true);
}
// Parameters must not defeat the match (servers append charset).
assert.equal(isHlsMimeType("application/vnd.apple.mpegurl; charset=utf-8"), true);
assert.equal(isHlsMimeType("video/mp4"), false);
assert.equal(isHlsMimeType("text/plain"), false);
assert.equal(isHlsMimeType(""), false);
assert.equal(isHlsMimeType(null), false);
});
test("isHlsSource accepts either the URL or an imeta mime hint", () => {
assert.equal(isHlsSource({ url: "https://cdn.example/v.m3u8" }), true);
assert.equal(
isHlsSource({ url: "https://cdn.example/stream", mimeType: "application/vnd.apple.mpegurl" }),
true
);
assert.equal(isHlsSource({ url: "https://cdn.example/v.mp4" }), false);
assert.equal(isHlsSource({}), false);
assert.equal(isHlsSource(), false);
});
// --- SCN-hls-engine-choice ------------------------------------------------
// Given a browser that CLAIMS native HLS support via canPlayType, when MSE is
// also available, then hls.js must win. This is not a preference — it is a
// correctness requirement measured in real browsers:
//
// Firefox 146: canPlayType("application/vnd.apple.mpegurl") === ""
// bare <video> → MEDIA_ERR_SRC_NOT_SUPPORTED (code 4)
// Chrome 149: canPlayType(...) === "maybe" ← and it is genuinely true there
//
// canPlayType is only ever a hint, so the engine choice keys off MSE (what
// hls.js actually needs) and falls back to native only when MSE is absent.
const makeFakeVideo = (canPlayTypeResult = "") => ({
src: "",
canPlayType: () => canPlayTypeResult,
});
// Node has no MediaSource; install/remove a fake to drive supportsMse().
const withMediaSource = async (isTypeSupported, fn) => {
const had = "MediaSource" in globalThis;
const original = globalThis.MediaSource;
globalThis.MediaSource = { isTypeSupported };
try {
return await fn();
} finally {
if (had) {
globalThis.MediaSource = original;
} else {
delete globalThis.MediaSource;
}
}
};
test("supportsNativeHls reflects canPlayType for the playlist types", () => {
assert.equal(supportsNativeHls(makeFakeVideo("maybe")), true);
assert.equal(supportsNativeHls(makeFakeVideo("probably")), true);
assert.equal(supportsNativeHls(makeFakeVideo("")), false);
});
test("supportsNativeHls survives a throwing canPlayType", () => {
const hostile = {
canPlayType() {
throw new Error("nope");
},
};
assert.equal(supportsNativeHls(hostile), false);
});
test("supportsMse tracks MediaSource.isTypeSupported", async () => {
assert.equal(supportsMse(), false, "no MediaSource in this environment");
await withMediaSource(() => true, () => {
assert.equal(supportsMse(), true);
});
await withMediaSource(() => false, () => {
assert.equal(supportsMse(), false);
});
await withMediaSource(() => {
throw new Error("boom");
}, () => {
assert.equal(supportsMse(), false, "a throwing check must not propagate");
});
});
test("prefersNativeHls is false whenever MSE can back hls.js", async () => {
const chromeLike = makeFakeVideo("maybe");
await withMediaSource(() => true, () => {
// Chrome's canPlayType says "maybe" — hls.js must still win.
assert.equal(prefersNativeHls(chromeLike), false);
});
// Only with MSE genuinely gone does native become the choice.
assert.equal(prefersNativeHls(chromeLike), true);
assert.equal(prefersNativeHls(makeFakeVideo("")), false, "neither path works");
});
test("attachHlsSource prefers hls.js over a native claim when MSE exists", async () => {
const video = makeFakeVideo("maybe");
const { engine, instances } = makeFakeEngine();
const handle = await withMediaSource(() => true, () =>
attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
})
);
assert.equal(handle.mode, "mse");
assert.equal(video.src, "", "must not use the native src path");
assert.equal(instances.length, 1);
});
test("attachHlsSource uses native playback when MSE is unavailable", async () => {
const video = makeFakeVideo("maybe");
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8");
assert.ok(handle, "expected a handle");
assert.equal(handle.mode, "native");
assert.equal(video.src, "https://cdn.example/v.m3u8");
// Nothing to destroy on the native path, but destroy() must stay callable.
assert.doesNotThrow(() => handle.destroy());
assert.equal(getAttachedHls(video), null);
});
test("attachHlsSource falls back to native when the engine fails to load", async () => {
const video = makeFakeVideo("maybe");
const handle = await withMediaSource(() => true, () =>
attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => null,
})
);
assert.equal(handle.mode, "native", "an attempt beats a guaranteed failure");
assert.equal(video.src, "https://cdn.example/v.m3u8");
});
test("attachHlsSource trims the URL before assigning it", async () => {
const video = makeFakeVideo("maybe");
await attachHlsSource(video, " https://cdn.example/v.m3u8\n");
assert.equal(video.src, "https://cdn.example/v.m3u8");
});
test("attachHlsSource returns null for missing element or URL", async () => {
assert.equal(await attachHlsSource(null, "https://cdn.example/v.m3u8"), null);
assert.equal(await attachHlsSource(makeFakeVideo("maybe"), ""), null);
assert.equal(await attachHlsSource(makeFakeVideo("maybe"), null), null);
});
// --- MSE test double ------------------------------------------------------
// A minimal stand-in for the hls.js constructor: enough surface for the
// attach/teardown/error contract, with hooks to drive events deterministically.
// `autoManifest` mirrors real hls.js, which parses the manifest asynchronously
// after loadSource(); tests that drive errors turn it off.
const HLS_EVENTS = {
MANIFEST_PARSED: "hlsManifestParsed",
ERROR: "hlsError",
};
const HLS_ERROR_TYPES = {
NETWORK_ERROR: "networkError",
MEDIA_ERROR: "mediaError",
OTHER_ERROR: "otherError",
};
function makeFakeEngine({ autoManifest = true, levels = [{ height: 240 }] } = {}) {
const instances = [];
class FakeHls {
static isSupported() {
return true;
}
static get Events() {
return HLS_EVENTS;
}
static get ErrorTypes() {
return HLS_ERROR_TYPES;
}
constructor(config) {
this.config = config;
this.handlers = new Map();
this.destroyCalls = 0;
this.startLoadCalls = 0;
this.recoverMediaErrorCalls = 0;
this.swapAudioCodecCalls = 0;
this.attachedMedia = null;
this.loadedSource = null;
instances.push(this);
}
on(event, handler) {
if (!this.handlers.has(event)) {
this.handlers.set(event, []);
}
this.handlers.get(event).push(handler);
}
emit(event, data) {
for (const handler of this.handlers.get(event) || []) {
handler(event, data);
}
}
attachMedia(media) {
this.attachedMedia = media;
}
loadSource(url) {
this.loadedSource = url;
if (autoManifest) {
// Real hls.js parses asynchronously; keep that ordering.
queueMicrotask(() => this.emit(HLS_EVENTS.MANIFEST_PARSED, { levels }));
}
}
startLoad() {
this.startLoadCalls += 1;
}
recoverMediaError() {
this.recoverMediaErrorCalls += 1;
}
swapAudioCodec() {
this.swapAudioCodecCalls += 1;
}
destroy() {
this.destroyCalls += 1;
}
}
return { engine: FakeHls, instances };
}
// --- SCN-hls-mse ----------------------------------------------------------
// Given an MSE browser, when a playlist parses with at least one playable
// level, then attach resolves; when hls.js drops every level (e.g. an
// HEVC-only ladder on Firefox) or errors fatally, then attach fails with a
// specific, user-facing message instead of a bare "no playable source".
test("attachHlsSource resolves once the manifest parses, and configures hls.js", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine();
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
});
assert.equal(handle.mode, "mse");
// Native `src` must stay untouched — MSE owns the element now.
assert.equal(video.src, "");
// A bounded back-buffer matters for hour-long VOD on low-memory devices.
assert.equal(instances[0].config.backBufferLength, 90);
assert.equal(instances[0].config.enableWorker, true);
});
test("attachHlsSource fails when no rendition survives codec filtering", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine({ levels: [] });
const messages = [];
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
onFatalError: (message) => messages.push(message),
});
assert.equal(handle, null);
assert.equal(messages.length, 1);
assert.match(messages[0], /codec your browser supports/i);
assert.equal(instances[0].destroyCalls, 1, "must not leak the instance");
assert.equal(getAttachedHls(video), null);
});
test("attachHlsSource returns null when hls.js is unsupported here", async () => {
const video = makeFakeVideo("");
class Unsupported {
static isSupported() {
return false;
}
}
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => Unsupported,
});
assert.equal(handle, null);
assert.equal(video.src, "", "must not fall back to an unplayable src");
});
test("attachHlsSource returns null when the engine bundle fails to load", async () => {
const video = makeFakeVideo("");
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => null,
});
assert.equal(handle, null);
});
test("a fatal network error retries twice before giving up", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine({ autoManifest: false });
const messages = [];
const pending = attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
onFatalError: (message) => messages.push(message),
});
await Promise.resolve();
const instance = instances[0];
const networkError = {
fatal: true,
type: HLS_ERROR_TYPES.NETWORK_ERROR,
details: "manifestLoadError",
};
instance.emit(HLS_EVENTS.ERROR, networkError);
assert.equal(instance.startLoadCalls, 1);
instance.emit(HLS_EVENTS.ERROR, networkError);
assert.equal(instance.startLoadCalls, 2);
// Third fatal network error exhausts the retry budget.
instance.emit(HLS_EVENTS.ERROR, networkError);
assert.equal(instance.startLoadCalls, 2);
assert.equal(await pending, null);
assert.equal(messages.length, 1);
assert.match(messages[0], /network or CORS/i);
assert.equal(instance.destroyCalls, 1);
});
test("a fatal media error recovers, then swaps audio codec, then gives up", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine({ autoManifest: false });
const messages = [];
const pending = attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
onFatalError: (message) => messages.push(message),
});
await Promise.resolve();
const instance = instances[0];
const mediaError = {
fatal: true,
type: HLS_ERROR_TYPES.MEDIA_ERROR,
details: "bufferAppendError",
};
instance.emit(HLS_EVENTS.ERROR, mediaError);
assert.equal(instance.recoverMediaErrorCalls, 1);
assert.equal(instance.swapAudioCodecCalls, 0);
instance.emit(HLS_EVENTS.ERROR, mediaError);
assert.equal(instance.recoverMediaErrorCalls, 2);
assert.equal(instance.swapAudioCodecCalls, 1, "second attempt swaps codec");
instance.emit(HLS_EVENTS.ERROR, mediaError);
assert.equal(instance.recoverMediaErrorCalls, 2, "budget exhausted");
assert.equal(await pending, null);
assert.match(messages[0], /codec your browser cannot decode/i);
});
test("non-fatal errors never tear the stream down", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine({ autoManifest: false });
const messages = [];
const pending = attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
onFatalError: (message) => messages.push(message),
});
await Promise.resolve();
const instance = instances[0];
instance.emit(HLS_EVENTS.ERROR, {
fatal: false,
type: HLS_ERROR_TYPES.NETWORK_ERROR,
details: "fragLoadError",
});
assert.equal(instance.destroyCalls, 0);
assert.equal(messages.length, 0);
// A transient fragment error must still let the stream come up.
instance.emit(HLS_EVENTS.MANIFEST_PARSED, { levels: [{ height: 720 }] });
const handle = await pending;
assert.equal(handle.mode, "mse");
});
test("a stuck manifest fails via the attach timeout rather than hanging", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine({ autoManifest: false });
const messages = [];
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
manifestTimeoutMs: 20,
onFatalError: (message) => messages.push(message),
});
assert.equal(handle, null);
assert.match(messages[0], /did not load in time/i);
assert.equal(instances[0].destroyCalls, 1);
});
test("attachMedia throwing is reported, not propagated", async () => {
const video = makeFakeVideo("");
const { engine } = makeFakeEngine({ autoManifest: false });
const messages = [];
class Hostile extends engine {
attachMedia() {
throw new Error("MediaSource unavailable");
}
}
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => Hostile,
onFatalError: (message) => messages.push(message),
});
assert.equal(handle, null);
assert.match(messages[0], /could not be attached/i);
});
// --- SCN-hls-teardown -----------------------------------------------------
// Given an attached hls.js instance, when the modal tears the element down,
// then destroy() runs exactly once and the element is unbound — otherwise the
// transmuxing worker and segment fetches keep running after the modal closes.
test("detachHls destroys the bound hls.js instance exactly once", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine();
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
});
assert.ok(handle, "expected an MSE handle");
assert.equal(handle.mode, "mse");
const instance = instances[0];
assert.equal(getAttachedHls(video), instance);
assert.equal(instance.loadedSource, "https://cdn.example/v.m3u8");
assert.equal(instance.attachedMedia, video);
assert.equal(detachHls(video), true);
assert.equal(instance.destroyCalls, 1);
assert.equal(getAttachedHls(video), null);
// Second detach (e.g. modal teardown after the session already reset) must
// not double-destroy — hls.js throws on a destroyed instance.
assert.equal(detachHls(video), false);
assert.equal(instance.destroyCalls, 1);
});
test("handle.destroy() tears the instance down too", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine();
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
});
handle.destroy();
assert.equal(instances[0].destroyCalls, 1);
assert.equal(getAttachedHls(video), null);
});
test("re-attaching to the same element destroys the previous instance", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine();
await attachHlsSource(video, "https://cdn.example/a.m3u8", {
createEngine: async () => engine,
});
await attachHlsSource(video, "https://cdn.example/b.m3u8", {
createEngine: async () => engine,
});
assert.equal(instances.length, 2);
assert.equal(instances[0].destroyCalls, 1, "first instance must be destroyed");
assert.equal(instances[1].destroyCalls, 0);
assert.equal(instances[1].loadedSource, "https://cdn.example/b.m3u8");
assert.equal(getAttachedHls(video), instances[1]);
});
test("detachHls is a safe no-op for non-HLS elements and null", () => {
assert.equal(detachHls(null), false);
assert.equal(detachHls(undefined), false);
assert.equal(detachHls(makeFakeVideo("")), false);
});
test("getAttachedHls returns null when nothing is bound", () => {
assert.equal(getAttachedHls(makeFakeVideo("")), null);
assert.equal(getAttachedHls(null), null);
});
// --- SCN-hls-probe --------------------------------------------------------
// Given the URL-health badge probing an HLS URL on an MSE browser, when the
// playlist is CORS-readable and starts with #EXTM3U, then the source is "ok".
// A bare <video> probe (the old path) reported every such URL as offline, which
// combined with the magnet-less "unhealthy" stream badge marks the card
// confirmed-dead and hides it from the grid.
const withFetch = async (impl, fn) => {
const original = globalThis.fetch;
globalThis.fetch = impl;
try {
return await fn();
} finally {
globalThis.fetch = original;
}
};
test("probeHlsManifest returns ok for a readable #EXTM3U playlist", async () => {
const result = await withFetch(
async () => ({
ok: true,
text: async () =>
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=584301,CODECS="avc1.640015"\nlow.m3u8\n',
}),
() => probeHlsManifest("https://cdn.example/v.m3u8")
);
assert.equal(result, "ok");
});
test("probeHlsManifest tolerates leading whitespace/BOM-ish padding", async () => {
const result = await withFetch(
async () => ({ ok: true, text: async () => "\n\n #EXTM3U\n" }),
() => probeHlsManifest("https://cdn.example/v.m3u8")
);
assert.equal(result, "ok");
});
test("probeHlsManifest reports error for a non-playlist body", async () => {
const result = await withFetch(
async () => ({ ok: true, text: async () => "<html>404</html>" }),
() => probeHlsManifest("https://cdn.example/v.m3u8")
);
assert.equal(result, "error");
});
test("probeHlsManifest reports error for a non-2xx response", async () => {
const result = await withFetch(
async () => ({ ok: false, status: 403, text: async () => "#EXTM3U" }),
() => probeHlsManifest("https://cdn.example/v.m3u8")
);
assert.equal(result, "error");
});
test("probeHlsManifest reports error when the fetch rejects (CORS/DNS)", async () => {
const result = await withFetch(
async () => {
throw new TypeError("Failed to fetch");
},
() => probeHlsManifest("https://cdn.example/v.m3u8")
);
assert.equal(result, "error");
});
test("probeHlsManifest distinguishes a timeout from a hard error", async () => {
const result = await withFetch(
(_url, options) =>
new Promise((_resolve, reject) => {
// Never settles on its own; the probe's own AbortController must fire.
options?.signal?.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
});
}),
() => probeHlsManifest("https://cdn.example/v.m3u8", 30)
);
assert.equal(result, "timeout");
});
test("probeHlsManifest rejects empty input without touching the network", async () => {
let called = false;
const result = await withFetch(
async () => {
called = true;
return { ok: true, text: async () => "#EXTM3U" };
},
() => probeHlsManifest(" ")
);
assert.equal(result, "error");
assert.equal(called, false);
});
+28
View File
@@ -9,6 +9,8 @@ describe("NostrService", () => {
// Reset state
nostrService.videosMap = new Map();
nostrService.dmMessages = [];
nostrService.dmMessageIndex = new Map();
nostrService.dmActorPubkey = null;
// Mock the internal nostrClient
mockClient = {
@@ -145,4 +147,30 @@ describe("NostrService", () => {
assert.equal(nostrService.dmMessageIndex.size, 0);
});
});
describe("loadDirectMessages", () => {
it("uses the actor NIP-17 relay list for the initial historical inbox fetch", async () => {
const actor = "a".repeat(64);
const dmRelay = "wss://dm-relay.example.com";
mockClient.readRelays = ["wss://discovery.example.com"];
mockClient.pool.list.mock.mockImplementation(async () => [
{
pubkey: actor,
created_at: 10,
tags: [["relay", dmRelay]],
},
]);
mockClient.listDirectMessages = mock.fn(async () => []);
await nostrService.loadDirectMessages({ actorPubkey: actor, initialLoad: true });
assert.equal(mockClient.listDirectMessages.mock.callCount(), 1);
const [, options] = mockClient.listDirectMessages.mock.calls[0].arguments;
assert.deepEqual(
options.relays,
[dmRelay],
"historical NIP-17 messages must use the same relay list as the live subscription",
);
});
});
});
@@ -0,0 +1,162 @@
// SCN-autoplay-blocked-no-p2p-yank
//
// Given a video with BOTH a hosted URL and a magnet, when the browser refuses
// autoplay (a deep link opened from a nostr client arrives with no user
// gesture, so every mobile browser refuses), then bitvid must show "Press play"
// and WAIT — not treat the pause as a stalled source.
//
// It used to: the 3s start timeout fired regardless, the video was yanked into
// WebTorrent, and a swarm with no peers played nothing while the prompt the
// viewer was meant to act on had already been torn down. Desktop usually hides
// this because an established Media Engagement score lets autoplay through.
import { describe, test, before, after } from "node:test";
import assert from "node:assert/strict";
import { PlaybackService } from "../../js/services/playbackService.js";
function makeVideoStub({ autoplayAllowed = false } = {}) {
const listeners = new Map();
const el = {
error: null,
src: "",
srcObject: null,
currentTime: 0,
readyState: 0,
networkState: 0,
muted: false,
paused: true,
dataset: {},
playCalls: 0,
addEventListener(type, handler) {
if (!listeners.has(type)) listeners.set(type, []);
listeners.get(type).push(handler);
},
removeEventListener(type, handler) {
const arr = listeners.get(type) || [];
const i = arr.indexOf(handler);
if (i >= 0) arr.splice(i, 1);
},
dispatch(type) {
for (const h of [...(listeners.get(type) || [])]) h({ type });
},
hasAttribute: () => false,
removeAttribute() {},
setAttribute() {},
pause() {},
load() {},
play() {
el.playCalls += 1;
if (autoplayAllowed) {
el.paused = false;
// A real element signals playback started; the watchdog's success path
// keys off this. Without it even a healthy source would (correctly)
// fall back once the start timeout elapsed.
setTimeout(() => el.dispatch("playing"), 0);
return Promise.resolve();
}
const error = new Error("play() failed because the user didn't interact");
error.name = "NotAllowedError";
return Promise.reject(error);
},
};
return el;
}
const HOSTED_URL = "https://cdn.example/video.mp4";
const MAGNET = "magnet:?xt=urn:btih:024c6044dfcbadef3ac5abbfa8a36766dc0606f1";
describe("PlaybackService autoplay-blocked handling", () => {
before(() => {
global.window = {
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
};
global.document = { createElement: () => makeVideoStub() };
});
after(() => {
delete global.window;
delete global.document;
});
const run = async ({ autoplayAllowed }) => {
const video = makeVideoStub({ autoplayAllowed });
const statuses = [];
let torrentAttempted = false;
const service = new PlaybackService({
logger: () => {},
urlFirstEnabled: true,
isValidMagnetUri: () => true,
// MUST be set on the service: createSession() overwrites any
// playbackStartTimeout passed in its own options with this value, so a
// per-session override is silently ignored (and the test would then race
// the real 3s default and prove nothing).
playbackStartTimeout: 60,
});
const session = service.createSession({
url: HOSTED_URL,
magnet: MAGNET,
videoElement: video,
autoplay: true,
playViaWebTorrent: async () => {
torrentAttempted = true;
await new Promise(() => {}); // a swarm with no peers
},
});
session.on?.("status", (p) => statuses.push(p.message));
const outcome = await Promise.race([
session.execute(),
new Promise((r) => setTimeout(() => r({ source: "PENDING" }), 600)),
]);
return { outcome, statuses, torrentAttempted, video };
};
test("a blocked autoplay never falls back to WebTorrent", async () => {
const { statuses, torrentAttempted } = await run({ autoplayAllowed: false });
assert.equal(
torrentAttempted,
false,
`must not switch to P2P while awaiting a gesture; got: ${JSON.stringify(statuses)}`
);
assert.equal(
statuses.some((m) => /switching to webtorrent/i.test(m)),
false
);
});
test("the viewer is told to press play, and the prompt is the last word", async () => {
const { statuses } = await run({ autoplayAllowed: false });
assert.equal(
statuses.some((m) => /press play/i.test(m)),
true,
"viewer needs an actionable prompt"
);
// Nothing may overwrite that prompt with a failure message.
assert.match(statuses[statuses.length - 1], /press play/i);
});
test("the session stays pending rather than reporting no playable source", async () => {
const { outcome } = await run({ autoplayAllowed: false });
assert.equal(outcome.source, "PENDING");
assert.notEqual(outcome.source, null);
});
test("permitted autoplay is unaffected and still starts on the hosted URL", async () => {
// The guard must not suppress the timeout for everyone -- only while
// autoplay is actually blocked.
const { outcome, torrentAttempted, statuses } = await run({
autoplayAllowed: true,
});
assert.equal(torrentAttempted, false);
assert.equal(
statuses.some((m) => /press play/i.test(m)),
false
);
assert.equal(outcome.source, "url", "a healthy source still starts normally");
});
});
@@ -267,8 +267,18 @@ describe("PlaybackService Forced Source Logic", () => {
assert.equal(probeUrl.mock.callCount(), 0);
});
test("Forced Source 'torrent': Does NOT fallback to URL even if Torrent fails", async () => {
const probeUrl = mock.fn(async () => ({ outcome: "good" }));
test("Forced Source 'torrent': falls back to the hosted URL when the torrent definitively fails", async () => {
// Spec changed deliberately in playbackService.js: "Even when P2P was
// explicitly forced, a hosted URL is a better outcome than a hard 'No
// playable source found' if the torrent DEFINITIVELY failed." The code
// carries the rollback note (`restore && forcedSource !== "torrent"` to
// make forced P2P hard-fail), so the old assertion here -- source null,
// probe never called -- encodes behavior that was intentionally removed.
//
// It did not merely fail: because forced sources skip the start timeout,
// the now-reachable hosted attempt waited forever on a <video> that never
// emits, and the test hung ("Promise resolution is still pending").
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
const playViaWebTorrent = mock.fn(async () => {
throw new Error("Torrent failed");
@@ -283,9 +293,18 @@ describe("PlaybackService Forced Source Logic", () => {
forcedSource: "torrent",
});
const result = await session.start();
const startPromise = session.start();
assert.equal(result.source, null);
assert.equal(probeUrl.mock.callCount(), 0);
// Let the torrent attempt reject and the hosted fallback attach, then
// signal that the element actually started playing.
await new Promise(resolve => process.nextTick(resolve));
mock.timers.tick(10);
await new Promise(resolve => process.nextTick(resolve));
video.dispatchEvent(new window.Event("playing"));
const result = await startPromise;
assert.equal(playViaWebTorrent.mock.callCount(), 1, "P2P is still tried first");
assert.equal(result.source, "url", "a working CDN beats a hard failure");
});
});
@@ -0,0 +1,185 @@
// SCN-stale-media-error
//
// Given the modal <video> is reused across videos, teardownVideoElement() /
// resetVideoElement() clear `src` and call load(). Browsers respond by leaving a
// MEDIA_ERR_SRC_NOT_SUPPORTED ("Empty src attribute") MediaError on the element.
//
// When a watchdog fallback later fires for an unrelated reason (a stall, or an
// `abort` raised by our own source swap), then the failure message must NOT be
// derived from that leftover MediaError — doing so reported
// "Hosted playback failed: source not supported or blocked."
// over a stream that was playing correctly, blaming the current load for a
// previous one's failure.
//
// Then: only a MediaError produced by THIS attempt may be surfaced.
import { describe, test, before, after } from "node:test";
import assert from "node:assert/strict";
import { PlaybackService } from "../../js/services/playbackService.js";
// Minimal <video> double: records listeners so the test can dispatch the exact
// media events the real watchdogs bind to.
function makeVideoStub({ initialError = null } = {}) {
const listeners = new Map();
return {
error: initialError,
src: "",
srcObject: null,
currentTime: 0,
readyState: 0,
networkState: 0,
muted: false,
dataset: {},
listeners,
addEventListener(type, handler) {
if (!listeners.has(type)) listeners.set(type, []);
listeners.get(type).push(handler);
},
removeEventListener(type, handler) {
const arr = listeners.get(type) || [];
const i = arr.indexOf(handler);
if (i >= 0) arr.splice(i, 1);
},
dispatch(type) {
for (const h of [...(listeners.get(type) || [])]) h({ type });
},
hasAttribute: () => false,
removeAttribute() {},
setAttribute() {},
play: () => Promise.resolve(),
pause() {},
load() {},
};
}
const EMPTY_SRC_ERROR = Object.freeze({
code: 4, // MEDIA_ERR_SRC_NOT_SUPPORTED
message: "MEDIA_ELEMENT_ERROR: Empty src attribute",
});
describe("PlaybackService stale MediaError handling", () => {
before(() => {
global.window = {
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
};
global.document = { createElement: () => makeVideoStub() };
});
after(() => {
delete global.window;
delete global.document;
});
const runWatchdogFallback = async ({ initialError, errorAtFallback }) => {
const video = makeVideoStub({ initialError });
const service = new PlaybackService({
logger: () => {},
urlFirstEnabled: true,
isValidMagnetUri: () => false,
});
const statuses = [];
let fallbackReason = null;
// Drive the watchdog contract directly: this is the boundary where the
// message is chosen, independent of network or codec support.
const cleanup = service.registerUrlPlaybackWatchdogs(video, {
stallMs: 0,
onSuccess: () => {},
onFallback: (reason) => {
fallbackReason = reason;
},
});
video.error = errorAtFallback;
video.dispatch("stalled");
cleanup();
return { statuses, fallbackReason, video };
};
test("a fallback still reports its reason to the caller", async () => {
const { fallbackReason } = await runWatchdogFallback({
initialError: EMPTY_SRC_ERROR,
errorAtFallback: EMPTY_SRC_ERROR,
});
assert.equal(fallbackReason, "stalled");
});
test("the leftover empty-src MediaError is identity-distinguishable", () => {
// The fix hinges on identity: starting a new load clears `error` to null,
// and a genuine new failure allocates a DIFFERENT MediaError object. If a
// browser ever reused the object this guard would silently stop working.
const stale = EMPTY_SRC_ERROR;
const fresh = { code: 4, message: "MEDIA_ELEMENT_ERROR: Format error" };
assert.notEqual(stale, fresh);
assert.equal(stale === EMPTY_SRC_ERROR, true);
});
// Drive a real session to the watchdog-fallback branch with a stale error
// already on the element, and capture what the viewer would be shown.
const runSessionWithStall = async ({ initialError, errorAtFallback }) => {
const video = makeVideoStub({ initialError });
// Fire `stalled` as soon as the session arms its watchdogs — this is the
// spurious fallback the viewer hits while the stream is actually fine.
const originalAdd = video.addEventListener.bind(video);
video.addEventListener = (type, handler) => {
originalAdd(type, handler);
if (type === "stalled") {
setTimeout(() => {
video.error = errorAtFallback;
video.dispatch("stalled");
}, 0);
}
};
const service = new PlaybackService({
logger: () => {},
urlFirstEnabled: true,
isValidMagnetUri: () => false,
});
const statuses = [];
const session = service.createSession({
url: "https://cdn.example/video.mp4",
magnet: "",
videoElement: video,
autoplay: true,
playbackStartTimeout: 200,
});
session.on?.("status", (p) => statuses.push(p.message));
await session.execute();
return statuses;
};
test("a stale empty-src error is NOT blamed on the current load", async () => {
const statuses = await runSessionWithStall({
initialError: EMPTY_SRC_ERROR,
errorAtFallback: EMPTY_SRC_ERROR, // unchanged => stale
});
assert.equal(
statuses.some((m) => /source not supported or blocked/i.test(m)),
false,
`stale error must not be reported; got: ${JSON.stringify(statuses)}`
);
});
test("a MediaError raised by THIS load is still reported", async () => {
// The guard must not silence genuine failures — a new MediaError object
// appearing during the attempt is real and must reach the viewer.
const statuses = await runSessionWithStall({
initialError: null,
errorAtFallback: {
code: 4,
message: "MEDIA_ELEMENT_ERROR: Format error",
},
});
assert.equal(
statuses.some((m) => /source not supported or blocked/i.test(m)),
true,
`genuine error must be reported; got: ${JSON.stringify(statuses)}`
);
});
});
+12 -2
View File
@@ -133,14 +133,24 @@ describe("trustBootstrap", () => {
assert.ok(seeds.has("editor1"));
});
it("should subscribe to accessControl changes", async () => {
it("should subscribe to the accessControl lists that affect seeds", async () => {
await bootstrapTrustedSeeds();
assert.equal(accessControl.onWhitelistChange.mock.callCount(), 1);
assert.equal(accessControl.onEditorsChange.mock.callCount(), 1);
assert.equal(accessControl.onBlacklistChange.mock.callCount(), 1);
});
it("should NOT subscribe to whitelist changes", async () => {
// buildTrustedSeeds() derives seeds from the super admin, the editors list
// and the fallback seeds -- the whitelist is not an input, so re-applying
// seeds on a whitelist change is pure churn. 18e08bf2 ("refine trust
// controls") removed that subscription deliberately; this pins the removal
// so it is not reinstated by reflex.
await bootstrapTrustedSeeds();
assert.equal(accessControl.onWhitelistChange.mock.callCount(), 0);
});
it("should handle accessControl timeout and apply seeds anyway", async () => {
// Mock waitForAccessControl to timeout (reject)
accessControl.waitForReady.mock.mockImplementation(async () => {
+190
View File
@@ -91,3 +91,193 @@ test("buildShareEvent tolerates missing optional fields", () => {
assert.equal(event.content, "");
assert.deepEqual(event.tags, []);
});
// SCN-share-imeta
// Given a share note whose body ends with the raw thumbnail URL, when the
// video has an http(s) thumbnail, then a NIP-92 `imeta` entry must describe it
// — many clients only render such a URL inline when imeta backs it, otherwise
// the viewer sees a bare link instead of a preview image.
const imetaOf = (event) => event.tags.find((tag) => tag[0] === "imeta") || null;
test("buildShareEvent emits a NIP-92 imeta entry for the thumbnail", () => {
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000804,
video: {
id: "a".repeat(64),
pubkey: "b".repeat(64),
title: "Why I'm (sort of) not worried about AI",
thumbnail: "https://cdn.example/thumb.jpg",
},
});
assert.deepEqual(imetaOf(event), [
"imeta",
"url https://cdn.example/thumb.jpg",
"m image/jpeg",
"alt Why I'm (sort of) not worried about AI",
]);
});
test("buildShareEvent infers the image type per extension, query strings included", () => {
const typeFor = (thumbnail) => {
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000805,
video: { id: "a".repeat(64), thumbnail },
});
return (imetaOf(event) || []).find((entry) => entry.startsWith("m ")) || "";
};
assert.equal(typeFor("https://cdn.example/a.png"), "m image/png");
assert.equal(typeFor("https://cdn.example/a.WEBP"), "m image/webp");
assert.equal(typeFor("https://cdn.example/a.jpeg?sig=abc&exp=1"), "m image/jpeg");
assert.equal(typeFor("https://cdn.example/a.avif#x"), "m image/avif");
});
test("buildShareEvent omits the media type when the extension is unknown", () => {
// A wrong `m` is worse than a missing one: clients use it to decide whether
// to render inline at all. Blossom-style extensionless URLs hit this path.
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000806,
video: {
id: "a".repeat(64),
title: "Untitled",
thumbnail: "https://blossom.example/0101591966f5764e57abb79f8e0305d7",
},
});
assert.deepEqual(imetaOf(event), [
"imeta",
"url https://blossom.example/0101591966f5764e57abb79f8e0305d7",
"alt Untitled",
]);
});
test("buildShareEvent skips imeta for non-http thumbnails", () => {
for (const thumbnail of [
"",
" ",
"assets/jpg/fallback.jpg",
"data:image/png;base64,iVBORw0KGgo=",
"javascript:alert(1)",
null,
undefined,
]) {
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000807,
video: { id: "a".repeat(64), thumbnail },
});
assert.equal(
imetaOf(event),
null,
`expected no imeta for thumbnail ${JSON.stringify(thumbnail)}`
);
}
});
test("buildShareEvent collapses newlines in imeta alt text", () => {
// imeta entries are space-delimited "key value" strings; a raw newline in the
// title would corrupt the tag for parsers reading it back.
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000808,
video: {
id: "a".repeat(64),
title: "Line one\nline two\t spaced",
thumbnail: "https://cdn.example/t.png",
},
});
const alt = imetaOf(event).find((entry) => entry.startsWith("alt "));
assert.equal(alt, "alt Line one line two spaced");
assert.equal(/[\n\r\t]/.test(alt), false);
});
// SCN-share-quote
// When a NIP-71 mirror exists, the note body quotes it via `nostr:naddr1…`
// instead of carrying a bare thumbnail URL. The imeta tag must then be dropped
// — the quote card already renders the thumbnail, and emitting both showed the
// same image twice — while an `a` tag links the note to the quoted coordinate.
const MIRROR_COORD = `34235:${"b".repeat(64)}:root-abc`;
test("buildShareEvent adds an `a` tag for the quoted mirror", () => {
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000809,
video: {
id: "a".repeat(64),
pubkey: "b".repeat(64),
title: "Quoted video",
thumbnail: "https://cdn.example/thumb.jpg",
mirrorNaddr: "naddr1qqxnzd3exuerjvfhx",
mirrorCoordinate: MIRROR_COORD,
},
});
const aTag = event.tags.find((tag) => tag[0] === "a");
assert.deepEqual(aTag, ["a", MIRROR_COORD, "", "mention"]);
});
test("buildShareEvent drops the imeta when the note quotes a mirror", () => {
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000810,
video: {
id: "a".repeat(64),
title: "Quoted video",
thumbnail: "https://cdn.example/thumb.jpg",
mirrorNaddr: "naddr1qqxnzd3exuerjvfhx",
mirrorCoordinate: MIRROR_COORD,
},
});
assert.equal(
event.tags.some((tag) => tag[0] === "imeta"),
false,
"quote card renders the thumbnail; a second imeta duplicates it"
);
});
test("buildShareEvent keeps the imeta when there is no mirror to quote", () => {
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000811,
video: {
id: "a".repeat(64),
title: "Unmirrored video",
thumbnail: "https://cdn.example/thumb.jpg",
},
});
assert.equal(event.tags.some((tag) => tag[0] === "imeta"), true);
assert.equal(event.tags.some((tag) => tag[0] === "a"), false);
});
test("buildShareEvent ignores a malformed mirror coordinate", () => {
for (const coordinate of [
"not-a-coordinate",
"34235:notahexpubkey:root",
":::",
"",
]) {
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000812,
video: {
id: "a".repeat(64),
title: "T",
mirrorCoordinate: coordinate,
},
});
assert.equal(
event.tags.some((tag) => tag[0] === "a"),
false,
`must not emit an a tag for ${JSON.stringify(coordinate)}`
);
}
});
+108
View File
@@ -0,0 +1,108 @@
// SCN-share-nostr-gate
//
// The share menu's "Share on Nostr" item is gated on `isLoggedIn && hasSigner`
// (js/ui/components/videoMenuRenderers.js). VideoModal initialized
// `shareNostrAuthState` to {false, false} and NOTHING ever wrote to it, so the
// item rendered permanently disabled — it looked like inert text, and the click
// delegation in bindShareMenuActions() bails on `button.disabled`.
//
// Two halves have to hold for the button to work:
// 1. VideoModal.setShareNostrAuthState() records the state.
// 2. syncAuthUiState() pushes real auth state in, and re-runs on auth change
// (it previously ran only once at bootstrap).
import test from "node:test";
import assert from "node:assert/strict";
import { VideoModal } from "../js/ui/components/VideoModal.js";
const PUBKEY = "b".repeat(64);
// --- VideoModal setter ----------------------------------------------------
function makeModal() {
const modal = Object.create(VideoModal.prototype);
modal.shareNostrAuthState = { isLoggedIn: false, hasSigner: false };
modal.closedPopover = 0;
modal.modalSharePopover = {
close: () => {
modal.closedPopover += 1;
},
};
return modal;
}
test("setShareNostrAuthState records the gate", () => {
const modal = makeModal();
assert.equal(modal.setShareNostrAuthState({ isLoggedIn: true, hasSigner: true }), true);
assert.deepEqual(modal.shareNostrAuthState, { isLoggedIn: true, hasSigner: true });
});
test("setShareNostrAuthState is idempotent and only closes a stale menu once", () => {
const modal = makeModal();
modal.setShareNostrAuthState({ isLoggedIn: true, hasSigner: true });
assert.equal(modal.closedPopover, 1, "open menu must re-render against new state");
// No change -> no churn. Closing the popover on every no-op sync would slam
// the menu shut under the user.
assert.equal(modal.setShareNostrAuthState({ isLoggedIn: true, hasSigner: true }), false);
assert.equal(modal.closedPopover, 1);
});
test("setShareNostrAuthState coerces and defaults safely", () => {
const modal = makeModal();
modal.setShareNostrAuthState({ isLoggedIn: "yes", hasSigner: 1 });
assert.deepEqual(modal.shareNostrAuthState, { isLoggedIn: true, hasSigner: true });
modal.setShareNostrAuthState();
assert.deepEqual(modal.shareNostrAuthState, { isLoggedIn: false, hasSigner: false });
});
test("setShareNostrAuthState tolerates no popover", () => {
const modal = makeModal();
modal.modalSharePopover = null;
assert.doesNotThrow(() =>
modal.setShareNostrAuthState({ isLoggedIn: true, hasSigner: true })
);
});
// --- app wiring ----------------------------------------------------------
// bitvid ALREADY routed auth changes to the modal: Application
// .updateShareNostrAuthState() is called on init, login, logout, pubkey-change
// and signer-change, and it forwards to videoModal.setShareNostrAuthState().
// That method simply did not exist, so the call short-circuited on its own
// `if (!this.videoModal?.setShareNostrAuthState) return;` guard and the gate
// stayed shut forever. These pin the contract that guard depends on.
test("the app's updateShareNostrAuthState contract is satisfied", async () => {
const { readFile } = await import("node:fs/promises");
const source = await readFile(new URL("../js/app.js", import.meta.url), "utf8");
// The guard short-circuits when the method is absent, which is exactly how
// this stayed broken silently. If the method is ever renamed, fail loudly.
assert.match(source, /videoModal\?\.setShareNostrAuthState/);
assert.match(source, /videoModal\.setShareNostrAuthState\(\{/);
// The lifecycle points that must keep the gate in sync.
for (const reason of ["init", "signer-change", "pubkey-change"]) {
assert.ok(
source.includes(`reason: "${reason}"`),
`app.js should refresh the share gate on ${reason}`
);
}
assert.equal(
typeof VideoModal.prototype.setShareNostrAuthState,
"function",
"VideoModal must expose the method app.js calls"
);
});
test("login and logout both refresh the gate", async () => {
const { readFile } = await import("node:fs/promises");
const source = await readFile(
new URL("../js/app/authSessionCoordinator.js", import.meta.url),
"utf8"
);
assert.ok(source.includes('reason: "auth-login"'));
assert.ok(source.includes('reason: "auth-logout"'));
});
+79
View File
@@ -0,0 +1,79 @@
// SCN-share-body: what the viewer actually reads in the composed note.
//
// Two shapes, chosen by whether a NIP-71 mirror was confirmed on relays:
// mirror -> quote card via `nostr:naddr1…`, NO bare thumbnail URL
// no mirror -> bare thumbnail URL (described by the note's NIP-92 imeta)
//
// The "no bare thumbnail while quoting" rule is the one that matters visually:
// keeping both rendered the same image twice (once loose, once in the card).
import test from "node:test";
import assert from "node:assert/strict";
import { ShareNostrModal } from "../js/ui/components/ShareNostrModal.js";
// buildShareContent is a pure string builder; construct without touching the
// DOM-dependent parts of the modal.
const modal = Object.create(ShareNostrModal.prototype);
const build = (payload) => modal.buildShareContent(payload);
const BASE = {
title: "Why I'm (sort of) not worried about AI",
shareUrl: "https://bitvid.network/?v=nevent1abc",
thumbnail: "https://cdn.example/thumb.jpg",
};
test("without a mirror the body ends with the thumbnail URL", () => {
const body = build(BASE);
assert.equal(
body,
"Why I'm (sort of) not worried about AI — Check out this video on bitvid 👇\n\n" +
"https://bitvid.network/?v=nevent1abc\n\n" +
"https://cdn.example/thumb.jpg"
);
});
test("with a mirror the body quotes it and omits the thumbnail URL", () => {
const body = build({ ...BASE, mirrorNaddr: "naddr1qqxnzd3e" });
assert.match(body, /nostr:naddr1qqxnzd3e$/);
assert.equal(
body.includes("https://cdn.example/thumb.jpg"),
false,
"quote card already shows the thumbnail; a loose URL duplicates it"
);
// The bitvid link stays — attribution and traffic still matter.
assert.match(body, /https:\/\/bitvid\.network\/\?v=nevent1abc/);
});
test("the quote reference uses the nostr: URI scheme clients resolve", () => {
const body = build({ ...BASE, mirrorNaddr: "naddr1qqxnzd3e" });
assert.equal(body.includes("nostr:naddr1qqxnzd3e"), true);
// A bare naddr without the scheme is rendered as plain text by most clients.
assert.equal(/(^|\s)naddr1qqxnzd3e/.test(body), false);
});
test("an empty or whitespace mirror pointer falls back to the thumbnail", () => {
for (const mirrorNaddr of ["", " ", null, undefined]) {
const body = build({ ...BASE, mirrorNaddr });
assert.match(body, /https:\/\/cdn\.example\/thumb\.jpg$/);
assert.equal(body.includes("nostr:"), false);
}
});
test("missing pieces never leave dangling blank sections", () => {
const noThumb = build({ title: "T", shareUrl: "https://bitvid.network/?v=x" });
assert.equal(noThumb.endsWith("\n"), false);
assert.equal(/\n{3,}/.test(noThumb), false);
const titleOnly = build({ title: "T" });
assert.equal(titleOnly, "T — Check out this video on bitvid 👇");
const empty = build({});
assert.equal(empty, "Untitled video — Check out this video on bitvid 👇");
});
test("a missing title degrades to a placeholder rather than 'undefined'", () => {
const body = build({ ...BASE, title: " " });
assert.match(body, /^Untitled video — /);
assert.equal(body.includes("undefined"), false);
});
+160
View File
@@ -0,0 +1,160 @@
// SCN-share-pointer-hints
//
// A shared bitvid link carries the video as an `nevent` in `?v=`. It used to be
// encoded as a BARE id -- no author, no relay hints -- so a viewer whose relay
// set didn't happen to carry that event could not resolve it and landed on the
// home feed. That is the "the shared link just opens bitvid.network" report:
// the reported video was on nos.lol but NOT on damus, snort or primal, three of
// bitvid's four defaults.
import test from "node:test";
import assert from "node:assert/strict";
import { createPlaybackCoordinator } from "../js/app/playbackCoordinator.js";
const EVENT_ID = "a759836ece8f5b16ee20fc9f5872ccb7a04208758a3c473f3b3ed219e3adcfd1";
const AUTHOR = "da19b5d291e06ed09ba545bc5366408dc82ba8c9e9f97d32ea8ea60f1f6cbfaa";
// Capture what gets encoded rather than asserting on bech32 output.
function makeHarness({ videos = [], relays = [], writeRelays } = {}) {
const encoded = [];
globalThis.window = {
NostrTools: {
nip19: {
neventEncode(pointer) {
encoded.push(pointer);
return `nevent1${pointer.id.slice(0, 8)}`;
},
},
},
};
const nostrClient = { relays };
if (writeRelays !== undefined) {
nostrClient.writeRelays = writeRelays;
}
const coordinator = createPlaybackCoordinator({
devLogger: { warn() {}, error() {}, log() {} },
userLogger: { warn() {}, error() {}, info() {} },
nostrClient,
BITVID_WEBSITE_URL: "https://bitvid.network/",
});
const app = {
...coordinator,
videosMap: new Map(videos.map((v) => [v.id, v])),
getShareUrlBase: () => "https://bitvid.network",
};
return { app, encoded };
}
test("the pointer carries the video author", () => {
const { app, encoded } = makeHarness({
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
relays: ["wss://relay.damus.io"],
});
app.buildShareUrlFromEventId(EVENT_ID);
assert.equal(encoded[0].author, AUTHOR);
});
test("the pointer carries relay hints", () => {
const { app, encoded } = makeHarness({
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
writeRelays: ["wss://nos.lol", "wss://relay.damus.io"],
});
app.buildShareUrlFromEventId(EVENT_ID);
assert.deepEqual(encoded[0].relays, ["wss://nos.lol", "wss://relay.damus.io"]);
});
test("write relays win over the general relay list", () => {
const { app, encoded } = makeHarness({
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
relays: ["wss://read-only.example"],
writeRelays: ["wss://nos.lol"],
});
app.buildShareUrlFromEventId(EVENT_ID);
assert.deepEqual(encoded[0].relays, ["wss://nos.lol"]);
});
test("relay hints are capped, deduped and scheme-filtered", () => {
// Hints inflate every shared URL, and a whole relay list in a note body is
// both unwieldy and more disclosure than the link needs.
const { app, encoded } = makeHarness({
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
writeRelays: [
"https://not-a-relay.example",
"wss://a.example",
" wss://a.example ",
"wss://b.example",
"ws://c.example",
"wss://d.example",
"",
null,
],
});
app.buildShareUrlFromEventId(EVENT_ID);
assert.deepEqual(encoded[0].relays, [
"wss://a.example",
"wss://b.example",
"ws://c.example",
]);
});
test("an unknown video still yields a usable pointer", () => {
// Sharing from a surface that has not cached the video must not break; the
// bare id is the old behavior and remains the floor.
const { app, encoded } = makeHarness({ writeRelays: ["wss://nos.lol"] });
const url = app.buildShareUrlFromEventId(EVENT_ID);
assert.equal(encoded[0].id, EVENT_ID);
assert.equal("author" in encoded[0], false);
assert.match(url, /^https:\/\/bitvid\.network\/\?v=/);
});
test("a malformed author is omitted rather than embedded", () => {
for (const pubkey of ["", "not-hex", "abc123", null, undefined, 42]) {
const { app, encoded } = makeHarness({
videos: [{ id: EVENT_ID, pubkey }],
writeRelays: ["wss://nos.lol"],
});
app.buildShareUrlFromEventId(EVENT_ID);
assert.equal(
"author" in encoded[0],
false,
`must not embed author ${JSON.stringify(pubkey)}`
);
}
});
test("no relays configured omits the hint rather than sending an empty array", () => {
const { app, encoded } = makeHarness({
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
relays: [],
});
app.buildShareUrlFromEventId(EVENT_ID);
assert.equal("relays" in encoded[0], false);
});
test("the share URL has a path separator before the query", () => {
// `https://bitvid.network?v=...` is legal but reads as a typo in a note and
// trips some link parsers on where the URL ends.
const { app } = makeHarness({
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
relays: ["wss://nos.lol"],
});
const url = app.buildShareUrlFromEventId(EVENT_ID);
assert.match(url, /^https:\/\/bitvid\.network\/\?v=nevent1/);
assert.equal(url.includes("network?v="), false);
});
test("an empty event id yields no URL", () => {
const { app } = makeHarness({ relays: ["wss://nos.lol"] });
assert.equal(app.buildShareUrlFromEventId(""), "");
assert.equal(app.buildShareUrlFromEventId(null), "");
});
+191
View File
@@ -83,6 +83,70 @@ test('ShareNostrController', async (t) => {
assert.equal(ui.showError.mock.calls.length, 0);
});
await t.test('handleShare forwards title and thumbnail for the imeta hint', async () => {
// The controller used to pass only { id, pubkey } to buildShareEvent, so
// the NIP-92 imeta entry could never be built and the thumbnail URL in the
// note body rendered as a bare link instead of a preview image.
const ui = {
showError: t.mock.fn(),
showSuccess: t.mock.fn(),
getModal: () => ({})
};
const state = {
getPubkey: () => 'signerpubkey',
normalizeHexPubkey: (k) => k,
getCurrentVideo: () => null
};
mockBuildShareEvent.mock.resetCalls();
const controller = new ShareNostrController({ ui, state, services });
await controller.handleShare({
video: {
id: 'video1',
title: 'Video 1',
pubkey: 'creator1',
thumbnail: ' https://cdn.example/thumb.jpg '
},
content: 'Check this out',
relays: ['wss://relay.example.com']
});
const passedVideo = mockBuildShareEvent.mock.calls[0].arguments[0].video;
assert.equal(passedVideo.title, 'Video 1');
assert.equal(passedVideo.thumbnail, 'https://cdn.example/thumb.jpg');
assert.equal(passedVideo.id, 'video1');
assert.equal(passedVideo.pubkey, 'creator1');
});
await t.test('handleShare tolerates a video with no thumbnail', async () => {
const ui = {
showError: t.mock.fn(),
showSuccess: t.mock.fn(),
getModal: () => ({})
};
const state = {
getPubkey: () => 'signerpubkey',
normalizeHexPubkey: (k) => k,
getCurrentVideo: () => null
};
mockBuildShareEvent.mock.resetCalls();
const controller = new ShareNostrController({ ui, state, services });
const result = await controller.handleShare({
video: { id: 'video1', title: 'Video 1', pubkey: 'creator1' },
content: 'Check this out',
relays: ['wss://relay.example.com']
});
assert.ok(result.ok);
assert.equal(
mockBuildShareEvent.mock.calls[0].arguments[0].video.thumbnail,
''
);
});
await t.test('handleShare throws error if missing video details', async () => {
const ui = {
showError: t.mock.fn(),
@@ -144,3 +208,130 @@ test('ShareNostrController', async (t) => {
assert.equal(openArg.video.shareUrl, 'https://example.com/v1');
});
});
// SCN-share-mirror-lookup
// The share modal embeds a quote pointer ONLY when a mirror is confirmed on
// relays. A pointer to a missing event renders as a broken/empty quote box in
// nostr clients — strictly worse than the plain-link fallback — so every
// failure path here must degrade to "no pointer" rather than guess.
test('ShareNostrController mirror pointer', async (t) => {
const NIP19 = { naddrEncode: (p) => `naddr1-${p.kind}-${p.identifier}` };
const baseUi = () => ({
showError: t.mock.fn(),
showSuccess: t.mock.fn(),
getModal: () => ({ open: async () => {} }),
});
const baseState = () => ({
getPubkey: () => 'signerpubkey',
normalizeHexPubkey: (k) => k,
getCurrentVideo: () => null,
buildShareUrlFromEventId: () => 'https://bitvid.network/?v=nevent1abc',
});
const VIDEO = {
id: 'video1',
title: 'Video 1',
pubkey: 'b'.repeat(64),
videoRootId: 'root-abc',
thumbnail: 'https://cdn.example/t.jpg',
width: 1280,
height: 720,
};
const makeController = (mirrorService) =>
new ShareNostrController({
ui: baseUi(),
state: baseState(),
services: {
mirrorService,
nip19: NIP19,
nostrClient: { pool: {}, writeRelays: ['wss://relay.one'] },
userLogger: { warn: () => {}, error: () => {}, info: () => {} },
devLogger: { warn: () => {}, error: () => {}, log: () => {} },
},
});
await t.test('confirmed mirror yields an naddr and coordinate', async () => {
const controller = makeController({
isAvailable: () => true,
findMirror: async () => ({ mirrored: true, kinds: [34235] }),
});
const out = await controller.resolveMirrorPointer(VIDEO);
assert.equal(out.naddr, 'naddr1-34235-root-abc');
assert.equal(out.coordinate, `34235:${'b'.repeat(64)}:root-abc`);
});
await t.test('the observed kind wins over the dimension heuristic', async () => {
// Portrait dims would suggest 34236; relays say 34235, so 34235 it is.
const controller = makeController({
isAvailable: () => true,
findMirror: async () => ({ mirrored: true, kinds: [34235] }),
});
const out = await controller.resolveMirrorPointer({
...VIDEO,
width: 720,
height: 1280,
});
assert.equal(out.naddr, 'naddr1-34235-root-abc');
});
await t.test('no mirror on relays yields no pointer', async () => {
const controller = makeController({
isAvailable: () => true,
findMirror: async () => ({ mirrored: false, kinds: [] }),
});
assert.deepEqual(await controller.resolveMirrorPointer(VIDEO), {
naddr: '',
coordinate: '',
});
});
await t.test('a throwing lookup degrades instead of breaking the share', async () => {
const controller = makeController({
isAvailable: () => true,
findMirror: async () => {
throw new Error('relay exploded');
},
});
assert.deepEqual(await controller.resolveMirrorPointer(VIDEO), {
naddr: '',
coordinate: '',
});
});
await t.test('a disabled mirror service is never consulted', async () => {
let called = false;
const controller = makeController({
isAvailable: () => false,
findMirror: async () => {
called = true;
return { mirrored: true, kinds: [34235] };
},
});
const out = await controller.resolveMirrorPointer(VIDEO);
assert.equal(out.naddr, '');
assert.equal(called, false);
});
await t.test('a video without a videoRootId cannot be addressed', async () => {
const controller = makeController({
isAvailable: () => true,
findMirror: async () => ({ mirrored: true, kinds: [34235] }),
});
const out = await controller.resolveMirrorPointer({
...VIDEO,
videoRootId: '',
});
assert.equal(out.naddr, '');
});
await t.test('a missing mirror service is tolerated', async () => {
const controller = makeController(null);
assert.deepEqual(await controller.resolveMirrorPointer(VIDEO), {
naddr: '',
coordinate: '',
});
});
});
+99
View File
@@ -1324,6 +1324,7 @@ test("loadSubscriptions uses overlap window and deterministic latest-event tie-b
subscribedPubkeys: [oldAuthor],
createdAt: 1000,
eventId: "cached-event",
eventPubkey: pubkey,
});
nostrClient.fetchListIncrementally = async (params = {}) => {
@@ -1378,6 +1379,104 @@ test("loadSubscriptions uses overlap window and deterministic latest-event tie-b
}
});
test("loadSubscriptions never applies a different BitLogin session actor's list", async () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
const activePubkey = "a".repeat(64);
const sessionPubkey = "b".repeat(64);
const activeAuthor = "c".repeat(64);
const sessionAuthor = "d".repeat(64);
const originalFetchIncremental = nostrClient.fetchListIncrementally;
const originalSessionActor = nostrClient.sessionActor;
const originalPool = nostrClient.pool;
const originalDecryptSubscriptionEvent = manager.decryptSubscriptionEvent;
nostrClient.sessionActor = { pubkey: sessionPubkey };
nostrClient.pool = { list: async () => [] };
nostrClient.fetchListIncrementally = async ({ pubkey }) => {
assert.equal(pubkey, activePubkey, "only the active account may be fetched");
return [
{
id: "active-list",
pubkey: activePubkey,
created_at: 100,
content: "active-ciphertext",
tags: [["encrypted", "nip04"]],
},
{
id: "foreign-list",
pubkey: sessionPubkey,
created_at: 200,
content: "foreign-ciphertext",
tags: [["encrypted", "nip04"]],
},
];
};
manager.decryptSubscriptionEvent = async (event) => ({
ok: true,
plaintext: JSON.stringify([["p", event.id === "active-list" ? activeAuthor : sessionAuthor]]),
});
try {
await manager.updateFromRelays(activePubkey, { allowPermissionPrompt: false });
assert.equal(manager.subsEventId, "active-list");
assert.deepEqual(manager.getSubscribedAuthors(), [activeAuthor]);
} finally {
nostrClient.fetchListIncrementally = originalFetchIncremental;
nostrClient.sessionActor = originalSessionActor;
nostrClient.pool = originalPool;
manager.decryptSubscriptionEvent = originalDecryptSubscriptionEvent;
localStorage.clear();
profileCache.memoryCache.clear();
profileCache.activePubkey = null;
}
});
test("loadSubscriptions repairs a legacy cached list with a full active-account fetch", async () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
const activePubkey = "e".repeat(64);
const staleAuthor = "f".repeat(64);
const correctAuthor = "1".repeat(64);
const originalFetchIncremental = nostrClient.fetchListIncrementally;
const originalPool = nostrClient.pool;
const originalDecryptSubscriptionEvent = manager.decryptSubscriptionEvent;
profileCache.setProfileData(activePubkey, "subscriptions", {
subscribedPubkeys: [staleAuthor],
eventId: "legacy-foreign-event",
createdAt: 9999,
});
nostrClient.pool = { list: async () => [] };
nostrClient.fetchListIncrementally = async (params) => {
assert.equal(params.since, 0, "legacy source-less cache must not suppress a full fetch");
return [{
id: "correct-active-event",
pubkey: activePubkey,
created_at: 100,
content: "correct-ciphertext",
tags: [["encrypted", "nip04"]],
}];
};
manager.decryptSubscriptionEvent = async () => ({
ok: true,
plaintext: JSON.stringify([["p", correctAuthor]]),
});
try {
await manager.updateFromRelays(activePubkey, { allowPermissionPrompt: false });
assert.deepEqual(manager.getSubscribedAuthors(), [correctAuthor]);
assert.equal(manager.subsEventPubkey, activePubkey);
} finally {
nostrClient.fetchListIncrementally = originalFetchIncremental;
nostrClient.pool = originalPool;
manager.decryptSubscriptionEvent = originalDecryptSubscriptionEvent;
localStorage.clear();
profileCache.memoryCache.clear();
profileCache.activePubkey = null;
}
});
test("handleSubscriptionListEvent does not re-trigger on the live-sub echo (loop guard)", () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
@@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { VideoListView } from "../../js/ui/views/VideoListView.js";
test("VideoListView disposes health observers before replacing its card grid", () => {
const dom = new JSDOM("<div id=grid></div>");
const grid = dom.window.document.getElementById("grid");
let healthDetaches = 0;
let urlDetaches = 0;
const view = new VideoListView({
document: dom.window.document,
badgeHelpers: {
attachHealthBadges: () => () => { healthDetaches += 1; },
attachUrlHealthBadges: () => () => { urlDetaches += 1; },
},
});
view.mount(grid);
const videos = [{ id: "one", title: "One", pubkey: "a", created_at: 1 }];
view.render(videos);
view.render([{ ...videos[0], title: "Updated", created_at: 2 }]);
view.unmount();
assert.equal(healthDetaches, 2, "rerender and unmount each detach health observers");
assert.equal(urlDetaches, 2, "rerender and unmount each detach URL observers");
dom.window.close();
});
test("VideoListView restores cards when a navigation remount clears unchanged feed markup", () => {
const dom = new JSDOM("<div id=grid></div>");
const grid = dom.window.document.getElementById("grid");
const view = new VideoListView({ document: dom.window.document });
const videos = [{ id: "for-you-one", title: "For You", pubkey: "a", created_at: 1 }];
view.mount(grid);
view.render(videos);
assert.equal(grid.querySelectorAll('[data-component="video-card"]').length, 1);
// Loading a view replaces #videoList. The data can still be identical when
// returning to For You, so a signature-only cache must not skip the repaint.
grid.innerHTML = '<div data-for-you-empty-state="true"></div>';
view.render(videos);
assert.equal(grid.querySelectorAll('[data-component="video-card"]').length, 1);
assert.equal(grid.querySelector('[data-for-you-empty-state]'), null);
dom.window.close();
});
+19
View File
@@ -125,14 +125,33 @@ test("VideoModal blurs and restores playback when moderation overlay toggles", a
});
await t.test("trusted mute blur state matches moderation context", async () => {
// Fixture mirrors what moderationDecorator actually emits. Two things
// made the old hand-rolled shape stop exercising this path:
//
// 1. normalizeVideoModerationContext derives `shouldShow` from
// `original.blurThumbnail` and `trustedCount`, NOT from a flat
// `blurThumbnail`/`trustedMuteCount`. The old fixture set fields the
// helper never reads, so shouldShow was false and applyModerationOverlay
// took its early return and deleted the blur it was asserting on.
// 2. 4525c1c6 made a lone trusted mute a non-blocking ranking signal (see
// "VideoCard keeps a single trusted mute as a non-blocking ranking
// signal"), so trusted-mute alone no longer forces the overlay.
//
// The scenario under test is still "a trusted mute that DID trigger a
// blur renders blurred with a trusted-mute badge", so the fixture now
// carries the decorator's `original` block that represents exactly that.
const video = {
id: "event-trusted-mute",
title: "Trusted mute video",
moderation: {
blurThumbnail: true,
blurReason: "trusted-mute",
trustedMuted: true,
trustedCount: 2,
trustedMuteCount: 2,
trustedMuterDisplayNames: ["Carol", "Dave"],
trustedMuteDisplayNames: ["Carol", "Dave"],
original: { blurThumbnail: true, blurReason: "trusted-mute" },
},
};
@@ -141,6 +141,68 @@ test(".ogg url WITHOUT an audio imeta is kept (ambiguous → treated as video)",
assert.equal(parsed.url, "https://cdn.example/clip.ogg");
});
// HLS playlists are a CONTAINER format, not an assertion about content. The
// `audio/mpegurl` / `audio/x-mpegurl` spellings are legacy aliases for an HLS
// playlist of any kind, so they must not vote "audio-only" — otherwise an HLS
// video ladder published with the legacy type is misfiled as a podcast and
// silently dropped from every video feed.
test("HLS playlist events are kept regardless of the playlist mime spelling", () => {
const makeHlsEvent = (mime, id) => ({
id,
content: JSON.stringify({
version: 3,
title: "Why I'm (sort of) not worried about AI",
url: "https://almond.apps2.slidestr.net/3751b84f.m3u8",
videoRootId: `root-${id}`,
}),
tags: [
[
"imeta",
"dim 1280x720",
"url https://almond.apps2.slidestr.net/3751b84f.m3u8",
`m ${mime}`,
"duration 3512",
],
],
});
for (const mime of [
"application/vnd.apple.mpegurl",
"application/x-mpegurl",
"audio/mpegurl",
"audio/x-mpegurl",
"APPLICATION/VND.APPLE.MPEGURL",
]) {
const parsed = convertEventToVideo(makeHlsEvent(mime, `evt-hls-${mime}`));
assert.equal(
parsed.invalid,
false,
`HLS note with "m ${mime}" must not be filtered as audio-only`
);
assert.equal(parsed.url, "https://almond.apps2.slidestr.net/3751b84f.m3u8");
}
});
test("a genuine audio/* variant still filters an HLS-tagged note", () => {
// The playlist type abstains; it must not *rescue* a note that also carries a
// real audio-only variant and no video variant.
const audioHls = {
id: "evt-hls-audio",
content: JSON.stringify({
version: 3,
title: "Podcast, HLS packaged",
url: "https://cdn.example/episode.m3u8",
videoRootId: "root-hls-audio",
}),
tags: [
["imeta", "url https://cdn.example/episode.m3u8", "m application/x-mpegurl"],
["imeta", "url https://cdn.example/episode.mp3", "m audio/mpeg"],
],
};
assert.equal(convertEventToVideo(audioHls).invalid, true);
});
test("a video imeta variant wins even when an audio variant is also present", () => {
const mixed = {
id: "evt-mixed",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"contrast": "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 truncated
"default": "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 truncated
"light": "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 truncated
"contrast": "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 truncated
"default": "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 truncated
"light": "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 truncated
"video-modal-mobile": "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 truncated
}
+23
View File
@@ -57,8 +57,17 @@ class MockTorrent extends EventEmitter {
}
destroy(opts, cb) {
this.destroyed = true;
this.destroyedDuringWire = this.emittingWire === true;
if (typeof cb === 'function') cb();
}
emit(eventName, ...args) {
if (eventName === 'wire') this.emittingWire = true;
const result = super.emit(eventName, ...args);
if (eventName === 'wire') this.emittingWire = false;
return result;
}
}
describe("WebTorrent Regression Tests", () => {
@@ -118,4 +127,18 @@ describe("WebTorrent Regression Tests", () => {
assert.strictEqual(result.healthy, false, "Should be unhealthy");
assert.strictEqual(result.reason, "timeout", "Reason should be 'timeout'");
});
it("defers probe cleanup until after a successful wire callback returns", async () => {
const client = new TorrentClient({ webTorrentClass: MockWebTorrent });
const magnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567";
const result = await client.probePeers(magnet, {
timeoutMs: 1000,
urlList: ["http://localhost:8080/video.mp4"],
});
const torrent = client.probeClient.torrents[0];
assert.equal(result.healthy, true);
assert.equal(torrent.destroyed, true, "temporary probe torrent is cleaned up before its queue slot releases");
assert.equal(torrent.destroyedDuringWire, false, "probe cleanup is not synchronous with the wire event");
});
});
+22
View File
@@ -373,3 +373,25 @@ test("most-zapped sorter: sats desc, recency tie-break, muted sinks", () => {
"sats desc, newer wins ties, trusted-muted last regardless of sats",
);
});
test("most-zapped sorter keeps strict zap order when one creator has several top videos", () => {
const item = (id, author, sats, createdAt) => ({
video: { id, pubkey: author, created_at: createdAt },
metadata: {},
sats,
});
const items = [
item("creator-a-top", "creator-a", 900, 100),
item("creator-a-second", "creator-a", 800, 200),
item("creator-b", "creator-b", 700, 300),
];
const sorted = createMostZappedSorter()(items, {
runtime: { getZapTotal: (video) => items.find((entry) => entry.video === video)?.sats || 0 },
});
assert.deepEqual(
sorted.map((entry) => entry.video.id),
["creator-a-top", "creator-a-second", "creator-b"],
"a diversity rule must not move a lower-zapped video above a higher-zapped one",
);
});
+9 -3
View File
@@ -1,8 +1,14 @@
# TODO: innerHTML Migration for ShareNostrModal.js
- [ ] L107: `wrapper.innerHTML = html;` (Template loading)
- [x] L107: `wrapper.innerHTML = html;` (Template loading)
- Strategy: Use `new DOMParser().parseFromString(html, 'text/html')` and append children to `wrapper`.
- [ ] L274: `this.relayPills.innerHTML = "";` (Clearing content)
- [x] L274: `this.relayPills.innerHTML = "";` (Clearing content)
- Strategy: `this.relayPills.replaceChildren();` or `textContent = ""` (replaceChildren is preferred for performance).
- [ ] L300: `removeButton.innerHTML = '<svg ...>...</svg>';` (SVG Icon)
- [x] L300: `removeButton.innerHTML = '<svg ...>...</svg>';` (SVG Icon)
- Strategy: Create SVG elements using `document.createElementNS("http://www.w3.org/2000/svg", "svg")` and children.
---
**CLOSED 2026-07-30.** All three migrations already landed in later refactors —
`grep innerHTML js/ui/components/ShareNostrModal.js` returns nothing and the
innerHTML lint baseline holds. Kept for the record.
+52 -3
View File
@@ -30,6 +30,37 @@ tests → `npm run build` + `npm run test:unit` green → commit + push.
## Open — high priority (launch-blocking candidates)
### 35. Channel subscriber/follower counts — scoped, deferred
- [ ] Approve the public/private product definition and implement only after
protocol research, bounded profile-only reads, and privacy/performance
tests. Do **not** infer a global count from BitVid's encrypted private
subscription lists.
- **Scope, recommendation, and acceptance criteria:**
[`docs/channel-subscriber-counts-plan.md`](../docs/channel-subscriber-counts-plan.md).
### 34. Performance and efficiency — staged, measurement-first plan
- [x] Establish the local-only measurement harness (`js/performanceHarness.js`);
it is enabled only on loopback with
`localStorage.__bitvidPerformanceHarness__ = "1"`.
- [ ] Capture and retain the complete Stage 0 baseline scenarios (cache size,
worker lifetime, probe counts, browser CPU/RSS, and warm-feed behavior).
- [x] Complete Stage 1: explicit localhost relay diagnostics.
- [x] Complete Stage 2: lazy and coalesced Explore/For You indexes, including
cache-first rendering when returning to For You.
- [ ] **Stage 3 in progress:** observer teardown is shipped; cancellable URL and
WebTorrent queue/probe work remains.
- [ ] **Stage 7 in progress:** recommendation refresh coalescing is shipped;
bounded event-cache growth and full feed-refresh coalescing remain.
- [ ] Stage 5: service-worker/image-cache cadence.
- [ ] Stage 4 and Stage 6: measured playback-visual and bundle/lazy-loading
improvements.
- **Plan and acceptance criteria:**
[`docs/performance-efficiency-plan.md`](../docs/performance-efficiency-plan.md).
Preserve live Nostr ingestion, feed freshness, source fallback, WebTorrent
playback, moderation decisions, and recommendation inputs at every stage.
### 33. Login / profile / session persistence broken with NIP-46 (Amber) — AUDIT (BUG)
Reported 2026-06-25. All symptoms observed with a **NIP-46 remote signer (Amber)**.
Likely a shared root cause in session/identity persistence + multi-account handling.
@@ -72,13 +103,31 @@ Likely a shared root cause in session/identity persistence + multi-account handl
`SignerManager.logout` (previousPubkey/hadRemoteClient), and
`authService.switchProfile` (from/to + whether requestLogin resolved the expected
pubkey). **Remove these once the fix lands.**
- [x] **CODE-LEVEL SUSPECTS RE-AUDITED + PINNED (2026-07-30).** Every root cause the
2026-06-25 audit named has since been fixed by later work, verified end-to-end
by reading the current code:
- Per-account NIP-46 session storage exists (`bitvid:nip46:sessions:v2` map,
v1 slot = last-connected default, migration on read).
- `logout()` → `disconnectRemoteSigner({ keepStored: true })` — stored
sessions survive; the no-arg `clearStoredNip46Session()` clears ONLY the
v1 default slot, never the map (the wipe-all was fixed earlier).
- Switch is fully wired: coordinator sets `reuseStored: true` for nip46
targets → `switchProfile` passes `expectPubkey` → provider forwards it →
`useStoredRemoteSigner({ pubkey })` restores the TARGET account's session,
and forgets only the attempted account on access-denial.
- Boot restore (`scheduleStoredRemoteSignerRestore`) enforces the
access-control validator and fails scoped (nothing else cleared).
NEW: `tests/nip46-signer-lifecycle.test.mjs` pins the manager-level contract
(logout scoping, restore-failure scoping, bare-disconnect scoping) — mutation-
checked: reinjecting the historical keepStored/wipe-all bugs fails 2/6.
- [ ] **VERIFY / REPRODUCE (user action):** on the phone with Amber on
unstable.bitvid.network, exercise (1) fresh load, (2) switch profiles, (3) logout,
and capture the `[nip46-diag]` console lines for each — restore succeeded/FAILED,
switchProfile `matchesTarget`, and disconnect `keepStored`/`hadClient`/caller stack.
- [ ] **THEN fix** based on the captured trace (likely: per-profile NIP-46 session keyed
by pubkey; don't tear the shared client on a scoped logout; reconnect on
switch/restore) and REMOVE the temporary `[nip46-diag]` diagnostics.
If all three behave, close this item and REMOVE the `[nip46-diag]` diagnostics;
if not, the trace now points at whatever the unit contract does NOT cover
(likely UI/state reconciliation, e.g. restore-failure leaving "unknown user"
rendered instead of a reconnect prompt).
### 1. Delete is not fully working — tombstoned videos still show in the UI
- [x] **Root cause found + fixed** (`afb6200b`): deletes published only to the CAPPED
@@ -0,0 +1,76 @@
# Static asset caching on Vercel — 2026-07-31
Carried over from the BitUnlock/BitLogin deployment work. Findings below were
verified against the live site on 2026-07-31, not assumed.
## What is actually happening
`vercel.json` declares exactly three header rules — `/sw.min.js`, `/`, and
`/(.*\.html)`. Nothing covers `/css/*`, `/js/*`, `/vendor/*`, or `/assets/*`,
so those fall through to Vercel's default. Measured live:
| Asset | Cache-Control |
| ---------------------------- | ------------------------------------------ |
| `css/tailwind.generated.css` | `public, max-age=0, must-revalidate` |
| `js/app.js` | `public, max-age=0, must-revalidate` |
| `index.html` | `no-cache, must-revalidate` |
Every CSS and JS file revalidates on **every** page load. The 304s are cheap
but they are still round-trips, and they are on the critical render path.
## The trap: do NOT just add `immutable`
The obvious fix — port the old `immutable` + 1-year rules across — would be a
**regression**, not an improvement. Asset URLs in `index.html` are not
content-hashed:
```html
<link href="css/tailwind.generated.css" />
<script src="js/index.js"></script>
```
A long `max-age` with `immutable` on an unhashed filename tells every browser
to keep that exact file for a year and never revalidate. The next deploy
changes the file's contents but not its URL, so returning users are pinned to
stale CSS/JS with no way to bust it short of a hard reload — and `immutable`
specifically instructs the browser to skip revalidation even on reload in some
engines. `scripts/hash-dist.mjs` exists but writes a hash *manifest* for
deploy verification; it does not rename assets or rewrite references.
## Two ways forward
- [ ] **Option A (correct, more work): content-hash the asset filenames.**
Emit `css/tailwind.<hash>.css` / `js/index.<hash>.js` from the build and
rewrite references in the emitted HTML. Only then add
`public, max-age=31536000, immutable` for `/css/*`, `/js/*`, `/assets/*`.
This is the only combination where `immutable` is safe, because the URL
changes whenever the bytes do.
- [ ] **Option B (safe now, smaller win): a bounded max-age, no `immutable`.**
e.g. `public, max-age=600, stale-while-revalidate=86400` for `/css/*` and
`/js/*`. Repeat views inside the window skip the round-trip entirely,
a deploy is picked up within ten minutes, and nothing can pin a user to a
stale bundle. Choose this if hashing is not happening soon.
**DECIDED 2026-07-31: Option A.** Content-hash the filenames, then apply
`immutable`. Do not ship Option B as an interim step — the two are not
stackable, and a bounded `max-age` shipped now becomes the thing nobody
revisits.
## Also worth doing
- [ ] Cover `/vendor/*` in whichever option is chosen — those files
(`marked.min.js`, `highlight.min.js`) are third-party and genuinely
change rarely, so they benefit most.
- [ ] Confirm `/sw.min.js` keeps a short/no-cache policy under any change. A
cached service worker is the one asset that can persist a bad deploy
past a user's next visit.
- [ ] Re-measure with `curl -sI` after deploying. Vercel's dashboard settings
can override `vercel.json`, so the header on the wire is the only
source of truth.
## Corrected note
An earlier hand-off claimed bitvid had "~10 pre-existing test failures". That
is **not true** as of 2026-07-31 — `npm run test:unit` (13/13),
`npm run test:dm:unit` (2/2 and 4/4), and `npm run test:smoke` all pass on
`unstable`. Do not spend time chasing failures that are not there.
+26 -7
View File
@@ -1,18 +1,37 @@
{
"package": "@bitlogin/widget",
"version": "0.1.0",
"source": {
"repository": "https://github.com/PR0M3TH3AN/bitlogin.git",
"commit": "205b0678cc67ecbde18278b8f46e7f8270fb519c",
"commitDate": "2026-07-31T22:04:01-04:00",
"dirty": false,
"provenance": "attributed",
"toolchain": {
"node": "v22.23.1",
"vite": "^6.1.0"
}
},
"files": {
"bitlogin-shared-QIBe5Omw.js": {
"sha256": "7ce4ed0a5c3c7f744e864f55745f7fa78482cbfa19cd30eb551f67a415f55191",
"bytes": 288275
"bitlogin-shared--Zs4uvXt.js": {
"sha256": "550434b2ed3df975fc1d8896dca02f8622f2819d1fd9fa288049f2c22baa6bc4",
"bytes": 483718
},
"bitlogin-shared-BxVSgyOf.js": {
"sha256": "d7f157c2de3d4b81de0055df2582306df9d862706d1dad35ff4096d06e1c1c93",
"bytes": 545712
},
"bitlogin-shared-DTz11m56.js": {
"sha256": "c52f485c6390658e8da75d7c31f01167eb4f8c22d836f9d92e22c2b0885bf79e",
"bytes": 289118
},
"bitlogin.js": {
"sha256": "55312cef3462a2e41043b57bd3c87a80bad4e077dd6e529faf25fa55db4ba7a8",
"bytes": 67232
"sha256": "56eeb7584b0117e2e360c80d37a5bfddb2e1f7a8ee6d94995c24043c0e87b906",
"bytes": 77303
},
"cryptoWorker.js": {
"sha256": "49a421e1026ad94243ceb0653f3761820d4e98e19b8fbe469307cd692f9a8489",
"bytes": 79173
"sha256": "4d569dac3b808d118bf432d628f084c8c1d8ba76d94cf101030ace1077c63b36",
"bytes": 110827
}
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+507 -248
View File
File diff suppressed because it is too large. Load diff
+1795 -899
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
Copyright (c) 2017 Dailymotion (http://www.dailymotion.com)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
src/remux/mp4-generator.js and src/demux/exp-golomb.ts implementation in this project
are derived from the HLS library for video.js (https://github.com/videojs/videojs-contrib-hls)
That work is also covered by the Apache 2 License, following copyright:
Copyright (c) 2013-2015 Brightcove
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+40
View File
@@ -0,0 +1,40 @@
var O=Number.isFinite||function(n){return typeof n=="number"&&isFinite(n)},No=Number.isSafeInteger||function(n){return typeof n=="number"&&Math.abs(n)<=Bo},Bo=Number.MAX_SAFE_INTEGER||9007199254740991,K=(function(n){return n.NETWORK_ERROR="networkError",n.MEDIA_ERROR="mediaError",n.KEY_SYSTEM_ERROR="keySystemError",n.MUX_ERROR="muxError",n.OTHER_ERROR="otherError",n})({}),L=(function(n){return n.KEY_SYSTEM_NO_KEYS="keySystemNoKeys",n.KEY_SYSTEM_NO_ACCESS="keySystemNoAccess",n.KEY_SYSTEM_NO_SESSION="keySystemNoSession",n.KEY_SYSTEM_NO_CONFIGURED_LICENSE="keySystemNoConfiguredLicense",n.KEY_SYSTEM_LICENSE_REQUEST_FAILED="keySystemLicenseRequestFailed",n.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED="keySystemServerCertificateRequestFailed",n.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED="keySystemServerCertificateUpdateFailed",n.KEY_SYSTEM_SESSION_UPDATE_FAILED="keySystemSessionUpdateFailed",n.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED="keySystemStatusOutputRestricted",n.KEY_SYSTEM_STATUS_INTERNAL_ERROR="keySystemStatusInternalError",n.KEY_SYSTEM_DESTROY_MEDIA_KEYS_ERROR="keySystemDestroyMediaKeysError",n.KEY_SYSTEM_DESTROY_CLOSE_SESSION_ERROR="keySystemDestroyCloseSessionError",n.KEY_SYSTEM_DESTROY_REMOVE_SESSION_ERROR="keySystemDestroyRemoveSessionError",n.MANIFEST_LOAD_ERROR="manifestLoadError",n.MANIFEST_LOAD_TIMEOUT="manifestLoadTimeOut",n.MANIFEST_PARSING_ERROR="manifestParsingError",n.MANIFEST_INCOMPATIBLE_CODECS_ERROR="manifestIncompatibleCodecsError",n.LEVEL_EMPTY_ERROR="levelEmptyError",n.LEVEL_LOAD_ERROR="levelLoadError",n.LEVEL_LOAD_TIMEOUT="levelLoadTimeOut",n.LEVEL_PARSING_ERROR="levelParsingError",n.LEVEL_SWITCH_ERROR="levelSwitchError",n.AUDIO_TRACK_LOAD_ERROR="audioTrackLoadError",n.AUDIO_TRACK_LOAD_TIMEOUT="audioTrackLoadTimeOut",n.SUBTITLE_LOAD_ERROR="subtitleTrackLoadError",n.SUBTITLE_TRACK_LOAD_TIMEOUT="subtitleTrackLoadTimeOut",n.FRAG_LOAD_ERROR="fragLoadError",n.FRAG_LOAD_TIMEOUT="fragLoadTimeOut",n.FRAG_DECRYPT_ERROR="fragDecryptError",n.FRAG_PARSING_ERROR="fragParsingError",n.FRAG_GAP="fragGap",n.REMUX_ALLOC_ERROR="remuxAllocError",n.KEY_LOAD_ERROR="keyLoadError",n.KEY_LOAD_TIMEOUT="keyLoadTimeOut",n.BUFFER_ADD_CODEC_ERROR="bufferAddCodecError",n.BUFFER_INCOMPATIBLE_CODECS_ERROR="bufferIncompatibleCodecsError",n.BUFFER_APPEND_ERROR="bufferAppendError",n.BUFFER_APPENDING_ERROR="bufferAppendingError",n.BUFFER_STALLED_ERROR="bufferStalledError",n.BUFFER_FULL_ERROR="bufferFullError",n.BUFFER_SEEK_OVER_HOLE="bufferSeekOverHole",n.BUFFER_NUDGE_ON_STALL="bufferNudgeOnStall",n.ASSET_LIST_LOAD_ERROR="assetListLoadError",n.ASSET_LIST_LOAD_TIMEOUT="assetListLoadTimeout",n.ASSET_LIST_PARSING_ERROR="assetListParsingError",n.INTERSTITIAL_ASSET_ITEM_ERROR="interstitialAssetItemError",n.INTERNAL_EXCEPTION="internalException",n.INTERNAL_ABORTED="aborted",n.ATTACH_MEDIA_ERROR="attachMediaError",n.UNKNOWN="unknown",n})({}),m=(function(n){return n.MEDIA_ATTACHING="hlsMediaAttaching",n.MEDIA_ATTACHED="hlsMediaAttached",n.MEDIA_DETACHING="hlsMediaDetaching",n.MEDIA_DETACHED="hlsMediaDetached",n.MEDIA_ENDED="hlsMediaEnded",n.STALL_RESOLVED="hlsStallResolved",n.BUFFER_RESET="hlsBufferReset",n.BUFFER_CODECS="hlsBufferCodecs",n.BUFFER_CREATED="hlsBufferCreated",n.BUFFER_APPENDING="hlsBufferAppending",n.BUFFER_APPENDED="hlsBufferAppended",n.BUFFER_EOS="hlsBufferEos",n.BUFFERED_TO_END="hlsBufferedToEnd",n.BUFFER_FLUSHING="hlsBufferFlushing",n.BUFFER_FLUSHED="hlsBufferFlushed",n.MANIFEST_LOADING="hlsManifestLoading",n.MANIFEST_LOADED="hlsManifestLoaded",n.MANIFEST_PARSED="hlsManifestParsed",n.LEVEL_SWITCHING="hlsLevelSwitching",n.LEVEL_SWITCHED="hlsLevelSwitched",n.LEVEL_LOADING="hlsLevelLoading",n.LEVEL_LOADED="hlsLevelLoaded",n.LEVEL_UPDATED="hlsLevelUpdated",n.LEVEL_PTS_UPDATED="hlsLevelPtsUpdated",n.LEVELS_UPDATED="hlsLevelsUpdated",n.AUDIO_TRACKS_UPDATED="hlsAudioTracksUpdated",n.AUDIO_TRACK_SWITCHING="hlsAudioTrackSwitching",n.AUDIO_TRACK_SWITCHED="hlsAudioTrackSwitched",n.AUDIO_TRACK_LOADING="hlsAudioTrackLoading",n.AUDIO_TRACK_LOADED="hlsAudioTrackLoaded",n.AUDIO_TRACK_UPDATED="hlsAudioTrackUpdated",n.SUBTITLE_TRACKS_UPDATED="hlsSubtitleTracksUpdated",n.SUBTITLE_TRACKS_CLEARED="hlsSubtitleTracksCleared",n.SUBTITLE_TRACK_SWITCH="hlsSubtitleTrackSwitch",n.SUBTITLE_TRACK_LOADING="hlsSubtitleTrackLoading",n.SUBTITLE_TRACK_LOADED="hlsSubtitleTrackLoaded",n.SUBTITLE_TRACK_UPDATED="hlsSubtitleTrackUpdated",n.SUBTITLE_FRAG_PROCESSED="hlsSubtitleFragProcessed",n.CUES_PARSED="hlsCuesParsed",n.NON_NATIVE_TEXT_TRACKS_FOUND="hlsNonNativeTextTracksFound",n.INIT_PTS_FOUND="hlsInitPtsFound",n.FRAG_LOADING="hlsFragLoading",n.FRAG_LOAD_EMERGENCY_ABORTED="hlsFragLoadEmergencyAborted",n.FRAG_LOADED="hlsFragLoaded",n.FRAG_DECRYPTED="hlsFragDecrypted",n.FRAG_PARSING_INIT_SEGMENT="hlsFragParsingInitSegment",n.FRAG_PARSING_USERDATA="hlsFragParsingUserdata",n.FRAG_PARSING_METADATA="hlsFragParsingMetadata",n.FRAG_PARSED="hlsFragParsed",n.FRAG_BUFFERED="hlsFragBuffered",n.FRAG_CHANGED="hlsFragChanged",n.FPS_DROP="hlsFpsDrop",n.FPS_DROP_LEVELine truncated
Fragment duration: ${i.duration.toFixed(3)}
Time to underbuffer: ${x.toFixed(3)} s
Estimated load time for current fragment: ${U.toFixed(3)} s
Estimated load time for down switch fragment: ${G.toFixed(3)} s
TTFB estimate: ${_|0} ms
Current BW estimate: ${O(b)?b|0:"Unknown"} bps
New BW estimate: ${this.getBwEstimate()|0} bps
Switching to level ${H} @ ${k|0} bps`),a.nextLoadLevel=a.nextAutoLevel=H,this.clearTimer();let q=()=>{if(this.clearTimer(),this.fragCurrent===i&&this.hls.loadLevel===H&&H>0){let J=this.getStarvationDelay();if(this.warn(`Aborting inflight request ${H>0?"and switching down":""}
Fragment duration: ${i.duration.toFixed(3)} s
Time to underbuffer: ${J.toFixed(3)} s`),i.abortRequests(),this.fragCurrent=this.partCurrent=null,H>f){let j=this.findBestLevel(this.hls.levels[f].bitrate,f,H,0,J,1,1);j===-1&&(j=f),this.hls.nextLoadLevel=this.hls.nextAutoLevel=j,this.resetEstimator(this.hls.levels[j].bitrate)}}};p||U>G*2?q():this.timer=self.setInterval(q,G*1e3),a.trigger(m.FRAG_LOAD_EMERGENCY_ABORTED,{frag:i,part:r,stats:h})},this.hls=e,this.bwEstimator=this.initEstimator(),this.registerListeners()}resetEstimator(e){e&&(this.log(`setting initial bwe to ${e}`),this.hls.config.abrEwmaDefaultEstimate=e),this.firstSelection=-1,this.bwEstimator=this.initEstimator()}initEstimator(){let e=this.hls.config;return new di(e.abrEwmaSlowVoD,e.abrEwmaFastVoD,e.abrEwmaDefaultEstimate)}registerListeners(){let{hls:e}=this;e.on(m.MANIFEST_LOADING,this.onManifestLoading,this),e.on(m.FRAG_LOADING,this.onFragLoading,this),e.on(m.FRAG_LOADED,this.onFragLoaded,this),e.on(m.FRAG_BUFFERED,this.onFragBuffered,this),e.on(m.LEVEL_SWITCHING,this.onLevelSwitching,this),e.on(m.LEVEL_LOADED,this.onLevelLoaded,this),e.on(m.LEVELS_UPDATED,this.onLevelsUpdated,this),e.on(m.MAX_AUTO_LEVEL_UPDATED,this.onMaxAutoLevelUpdated,this),e.on(m.ERROR,this.onError,this)}unregisterListeners(){let{hls:e}=this;e&&(e.off(m.MANIFEST_LOADING,this.onManifestLoading,this),e.off(m.FRAG_LOADING,this.onFragLoading,this),e.off(m.FRAG_LOADED,this.onFragLoaded,this),e.off(m.FRAG_BUFFERED,this.onFragBuffered,this),e.off(m.LEVEL_SWITCHING,this.onLevelSwitching,this),e.off(m.LEVEL_LOADED,this.onLevelLoaded,this),e.off(m.LEVELS_UPDATED,this.onLevelsUpdated,this),e.off(m.MAX_AUTO_LEVEL_UPDATED,this.onMaxAutoLevelUpdated,this),e.off(m.ERROR,this.onError,this))}destroy(){this.unregisterListeners(),this.clearTimer(),this.hls=this._abandonRulesCheck=this.supportedCache=null,this.fragCurrent=this.partCurrent=null}onManifestLoading(e,t){this.lastLoadedFragLevel=-1,this.firstSelection=-1,this.lastLevelLoadSec=0,this.supportedCache={},this.fragCurrent=this.partCurrent=null,this.onLevelsUpdated(),this.clearTimer()}onLevelsUpdated(){this.lastLoadedFragLevel>-1&&this.fragCurrent&&(this.lastLoadedFragLevel=this.fragCurrent.level),this._nextAutoLevel=-1,this.onMaxAutoLevelUpdated(),this.codecTiers=null,this.audioTracksByGroup=null}onMaxAutoLevelUpdated(){this.firstSelection=-1,this.nextAutoLevelKey=""}onFragLoading(e,t){let s=t.frag;if(!this.ignoreFragment(s)){if(!s.bitrateTest){var i;this.fragCurrent=s,this.partCurrent=(i=t.part)!=null?i:null}this.clearTimer(),this.timer=self.setInterval(this._abandonRulesCheck,100)}}onLevelSwitching(e,t){this.clearTimer()}onError(e,t){if(!t.fatal)switch(t.details){case L.BUFFER_ADD_CODEC_ERROR:case L.BUFFER_APPEND_ERROR:this.lastLoadedFragLevel=-1,this.firstSelection=-1;break;case L.FRAG_LOAD_TIMEOUT:{let s=t.frag,{fragCurrent:i,partCurrent:r}=this;if(s&&i&&s.sn===i.sn&&s.level===i.level){let a=performance.now(),o=r?r.stats:s.stats,c=a-o.loading.start,l=o.loading.first?o.loading.first-o.loading.start:-1;if(o.loaded&&l>-1){let u=this.bwEstimator.getEstimateTTFB();this.bwEstimator.sample(c-Math.min(u,l),o.loaded)}else this.bwEstimator.sampleTTFB(c)}break}}}getTimeToLoadFrag(e,t,s,i){let r=e+s/t,a=i?e+this.lastLevelLoadSec:0;return r+a}onLevelLoaded(e,t){let s=this.hls.config,{loading:i}=t.stats,r=i.end-i.first;O(r)&&(this.lastLevelLoadSec=r/1e3),t.details.live?this.bwEstimator.update(s.abrEwmaSlowLive,s.abrEwmaFastLive):this.bwEstimator.update(s.abrEwmaSlowVoD,s.abrEwmaFastVoD),this.timer>-1&&this._abandonRulesCheck(t.levelInfo)}onFragLoaded(e,{frag:t,part:s}){let i=s?s.stats:t.stats;if(t.type===N.MAIN&&this.bwEstimator.sampleTTFB(i.loading.first-i.loading.start),!this.ignoreFragment(t)){if(this.clearTimer(),t.level===this._nextAutoLevel&&(this._nextAutoLevel=-1),this.firstSelection=-1,this.hls.config.abrMaxWithRealBitrate){let r=s?s.duration:t.duration,a=this.hls.levels[t.level],o=(a.loaded?a.loaded.bytes:0)+i.loaded,c=(a.loaded?a.loaded.duration:0)+r;a.loaded={bytes:o,duration:c},a.realBitrate=Math.round(8*o/c)}if(t.bitrateTest){let r={stats:i,frag:t,part:s,id:t.type};this.onFragBuffered(m.FRAG_BUFFERED,r),t.bitrateTest=!1}else this.lastLoadedFragLevel=t.level}}onFragBuffered(e,t){let{frag:s,part:i}=t,r=i!=null&&i.stats.loaded?i.stats:s.stats;if(r.aborted||this.ignoreFragment(s))return;let a=r.parsing.end-r.loading.start-Math.min(r.loading.first-r.loading.start,this.bwEstimator.getEstimateTTFB());this.bwEstimator.sample(a,r.loaded),r.bwEstimate=this.getBwEstimate(),s.bitrateTest?this.bitrateTestDelay=a/1e3:this.bitrateTestDelay=0}ignoreFragment(e){return e.type!==N.MAIN||e.sn==="initSegment"}clearTimer(){this.timer>-1&&(self.clearInterval(this.timer),this.timer=-1)}get firstAutoLevel(){let{maxAutoLevel:e,minAutoLevel:t}=this.hls,s=this.getBwEstimate(),i=this.hls.config.maxStarvationDelay,r=this.findBestLevel(s,t,e,0,i,1,1);if(r>-1)return r;let a=this.hls.firstLevel,o=Math.min(Math.max(a,t),e);return this.warn(`Could not find best starting auto level. Defaulting to first in Line truncated
Playlist starting @${e.startSN}
${e.m3u8}
Playlist starting @${t.startSN}
${t.m3u8}`)}function Ua(n,e,t=!0){let s=e.startSN+e.skippedSegments-n.startSN,i=n.fragments,r=s>=0,a=0;if(r&&s<i.length)a=i[s].start;else if(r&&e.startSN===n.endSN+1)a=n.fragmentEnd;else if(r&&t)a=n.fragmentStart+s*e.levelTargetDuration;else if(!e.skippedSegments&&e.fragmentStart===0)a=n.fragmentStart;else return;Pi(e,a)}function Pi(n,e){if(e){let t=n.fragments;for(let s=n.skippedSegments;s<t.length;s++)t[s].addStart(e);n.fragmentHint&&n.fragmentHint.addStart(e)}}function $a(n,e=1/0){let t=1e3*n.targetduration;if(n.updated){let s=n.fragments;if(s.length&&t*4>e){let r=s[s.length-1].duration*1e3;r<t&&(t=r)}}else t/=2;return Math.round(t)}function Ga(n,e,t){if(!n)return null;let s=n.fragments[e-n.startSN];return s||(s=n.fragmentHint,s&&s.sn===e)?s:e<n.startSN&&t&&t.sn===e?t:null}function Fn(n,e,t){return n?Ka(n.partList,e,t):null}function Ka(n,e,t){if(n)for(let s=n.length;s--;){let i=n[s];if(i.index===t&&i.fragment.sn===e)return i}return null}function Va(n){n.forEach((e,t)=>{var s;(s=e.details)==null||s.fragments.forEach(i=>{i.level=t,i.initSegment&&(i.initSegment.level=t)})})}function ac(n,e){return n!==e&&e?Nn(n)!==Nn(e):!1}function Nn(n){return n.replace(/\?[^?]*$/,"")}function Pt(n,e){for(let s=0,i=n.length;s<i;s++){var t;if(((t=n[s])==null?void 0:t.cc)===e)return n[s]}return null}function oc(n,e){return!!(n&&e.startCC<n.endCC&&e.endCC>n.startCC)}function Bn(n,e){let t=n.start+e;n.startPTS=t,n.setStart(t),n.endPTS=t+n.duration}function Ha(n,e){let t=e.fragments;for(let s=0,i=t.length;s<i;s++)Bn(t[s],n);e.fragmentHint&&Bn(e.fragmentHint,n),e.alignedSliding=!0}function lc(n,e){n&&(Ya(e,n),e.alignedSliding||Is(e,n),!e.alignedSliding&&!e.skippedSegments&&Ua(n,e,!1))}function Ya(n,e){if(!oc(e,n))return;let t=Math.min(e.endCC,n.endCC),s=Pt(e.fragments,t),i=Pt(n.fragments,t);if(!s||!i)return;te.log(`Aligning playlist at start of dicontinuity sequence ${t}`);let r=s.start-i.start;Ha(r,n)}function Is(n,e){if(!n.hasProgramDateTime||!e.hasProgramDateTime)return;let t=n.fragments,s=e.fragments;if(!t.length||!s.length)return;let i,r,a=Math.min(e.endCC,n.endCC);e.startCC<a&&n.startCC<a&&(i=Pt(s,a),r=Pt(t,a)),(!i||!r)&&(i=s[Math.floor(s.length/2)],r=Pt(t,i.cc)||t[Math.floor(t.length/2)]);let o=i.programDateTime,c=r.programDateTime;if(!o||!c)return;let l=(c-o)/1e3-(r.start-i.start);Ha(l,n)}function Se(n,e,t){xe(n,e,t),n.addEventListener(e,t)}function xe(n,e,t){n.removeEventListener(e,t)}var cc={toString:function(n){let e="",t=n.length;for(let s=0;s<t;s++)e+=`[${n.start(s).toFixed(3)}-${n.end(s).toFixed(3)}]`;return e}},D={STOPPED:"STOPPED",IDLE:"IDLE",KEY_LOADING:"KEY_LOADING",FRAG_LOADING:"FRAG_LOADING",FRAG_LOADING_WAITING_RETRY:"FRAG_LOADING_WAITING_RETRY",WAITING_TRACK:"WAITING_TRACK",PARSING:"PARSING",PARSED:"PARSED",ENDED:"ENDED",ERROR:"ERROR",WAITING_INIT_PTS:"WAITING_INIT_PTS",WAITING_LEVEL:"WAITING_LEVEL"},Bt=class extends Ss{constructor(e,t,s,i,r){super(i,e.logger),this.hls=void 0,this.fragPrevious=null,this.fragCurrent=null,this.fragmentTracker=void 0,this.transmuxer=null,this._state=D.STOPPED,this.playlistType=void 0,this.media=null,this.mediaBuffer=null,this.config=void 0,this.bitrateTest=!1,this.lastCurrentTime=0,this.nextLoadPosition=0,this.startPosition=0,this.startTimeOffset=null,this.retryDate=0,this.levels=null,this.fragmentLoader=void 0,this.keyLoader=void 0,this.levelLastLoaded=null,this.startFragRequested=!1,this.decrypter=void 0,this.initPTS=[],this.buffering=!0,this.loadingParts=!1,this.loopSn=void 0,this.onMediaSeeking=()=>{let{config:a,fragCurrent:o,media:c,mediaBuffer:l,state:h}=this,u=c?c.currentTime:0,d=W.bufferInfo(l||c,u,a.maxBufferHole),f=!d.len;if(this.log(`Media seeking to ${O(u)?u.toFixed(3):u}, state: ${h}, ${f?"out of":"in"} buffer`),this.state===D.ENDED)this.resetLoadingState();else if(o){let g=a.maxFragLookUpTolerance,y=o.start-g,p=o.start+o.duration+g;if(f||p<d.start||y>d.end){let E=u>p;(u<y||E)&&(E&&o.loader&&(this.log(`Cancelling fragment load for seek (sn: ${o.sn})`),o.abortRequests(),this.resetLoadingState()),this.fragPrevious=null)}}if(c){this.fragmentTracker.removeFragmentsInRange(u,1/0,this.playlistType,!0);let g=this.lastCurrentTime;if(u>g&&(this.lastCurrentTime=u),!this.loadingParts){let y=Math.max(d.end,u),p=this.shouldLoadParts(this.getLevelDetails(),y);p&&(this.log(`LL-Part loading ON after seeking to ${u.toFixed(2)} with buffer @${y.toFixed(2)}`),this.loadingParts=p)}}this.hls.hasEnoughToStart||(this.log(`Setting ${f?"startPosition":"nextLoadPosition"} to ${u} for seek without enough to start`),this.nextLoadPosition=u,f&&(this.startPosition=u)),f&&this.state===D.IDLE&&this.tickImmediate()},this.onMediaEnded=()=>{this.log("setting startPosition to 0 because media ended"),this.startPosition=this.lastCurrentTime=0},this.playlistType=r,this.hls=e,this.fragmentLoader=new Li(e.config),this.keyLoader=s,this.fragmentTracker=t,this.config=e.config,this.decrypter=new Ft(e.config)}registerListeners(){let{hls:e}=this;e.on(m.MEDIA_ATTACHED,this.onMediaAttached,this),e.on(m.MEDIA_DE Line truncated
discontinuity: ${T}
trackSwitch: ${S}
contiguous: ${A}
accurateTimeOffset: ${c}
timeOffset: ${y}
initSegmentChange: ${R}`);let P=new Vi(s,i,t,o,h);this.configureTransmuxer(P)}if(this.frag=r,this.part=a,this.workerContext)this.workerContext.worker.postMessage({instanceNo:f,cmd:"demux",data:e,decryptdata:p,chunkMeta:l,state:I},e instanceof ArrayBuffer?[e]:[]);else if(g){let P=g.push(e,p,l,I);Kt(P)?P.then(M=>{this.handleTransmuxComplete(M)}).catch(M=>{this.transmuxerError(M,l,"transmuxer-interface push error")}):this.handleTransmuxComplete(P)}}flush(e){e.transmuxing.start=self.performance.now();let{instanceNo:t,transmuxer:s}=this;if(this.workerContext)this.workerContext.worker.postMessage({instanceNo:t,cmd:"flush",chunkMeta:e});else if(s){let i=s.flush(e);Kt(i)?i.then(r=>{this.handleFlushResult(r,e)}).catch(r=>{this.transmuxerError(r,e,"transmuxer-interface flush error")}):this.handleFlushResult(i,e)}}transmuxerError(e,t,s){this.hls&&(this.error=e,this.hls.trigger(m.ERROR,{type:K.MEDIA_ERROR,details:L.FRAG_PARSING_ERROR,chunkMeta:t,frag:this.frag||void 0,part:this.part||void 0,fatal:!1,error:e,err:e,reason:s}))}handleFlushResult(e,t){e.forEach(s=>{this.handleTransmuxComplete(s)}),this.onFlush(t)}configureTransmuxer(e){let{instanceNo:t,transmuxer:s}=this;this.workerContext?this.workerContext.worker.postMessage({instanceNo:t,cmd:"configure",config:e}):s&&s.configure(e)}handleTransmuxComplete(e){e.chunkMeta.transmuxing.end=self.performance.now(),this.onTransmuxComplete(e)}},Wn=100,Yi=class extends Bt{constructor(e,t,s){super(e,t,s,"audio-stream-controller",N.AUDIO),this.mainAnchor=null,this.mainFragLoading=null,this.audioOnly=!1,this.bufferedTrack=null,this.switchingTrack=null,this.trackId=-1,this.waitingData=null,this.mainDetails=null,this.flushing=!1,this.bufferFlushed=!1,this.cachedTrackLoadedData=null,this.registerListeners()}onHandlerDestroying(){this.unregisterListeners(),super.onHandlerDestroying(),this.resetItem()}resetItem(){this.mainDetails=this.mainAnchor=this.mainFragLoading=this.bufferedTrack=this.switchingTrack=this.waitingData=this.cachedTrackLoadedData=null}registerListeners(){super.registerListeners();let{hls:e}=this;e.on(m.LEVEL_LOADED,this.onLevelLoaded,this),e.on(m.AUDIO_TRACKS_UPDATED,this.onAudioTracksUpdated,this),e.on(m.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),e.on(m.AUDIO_TRACK_LOADED,this.onAudioTrackLoaded,this),e.on(m.BUFFER_RESET,this.onBufferReset,this),e.on(m.BUFFER_CREATED,this.onBufferCreated,this),e.on(m.BUFFER_FLUSHING,this.onBufferFlushing,this),e.on(m.BUFFER_FLUSHED,this.onBufferFlushed,this),e.on(m.INIT_PTS_FOUND,this.onInitPtsFound,this),e.on(m.FRAG_LOADING,this.onFragLoading,this),e.on(m.FRAG_BUFFERED,this.onFragBuffered,this)}unregisterListeners(){let{hls:e}=this;e&&(super.unregisterListeners(),e.off(m.LEVEL_LOADED,this.onLevelLoaded,this),e.off(m.AUDIO_TRACKS_UPDATED,this.onAudioTracksUpdated,this),e.off(m.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),e.off(m.AUDIO_TRACK_LOADED,this.onAudioTrackLoaded,this),e.off(m.BUFFER_RESET,this.onBufferReset,this),e.off(m.BUFFER_CREATED,this.onBufferCreated,this),e.off(m.BUFFER_FLUSHING,this.onBufferFlushing,this),e.off(m.BUFFER_FLUSHED,this.onBufferFlushed,this),e.off(m.INIT_PTS_FOUND,this.onInitPtsFound,this),e.off(m.FRAG_LOADING,this.onFragLoading,this),e.off(m.FRAG_BUFFERED,this.onFragBuffered,this))}onInitPtsFound(e,{frag:t,id:s,initPTS:i,timescale:r,trackId:a}){if(s===N.MAIN){let o=t.cc,c=this.fragCurrent;if(this.initPTS[o]={baseTime:i,timescale:r,trackId:a},this.log(`InitPTS for cc: ${o} found from main: ${i/r} (${i}/${r}) trackId: ${a}`),this.mainAnchor=t,this.state===D.WAITING_INIT_PTS){let l=this.waitingData;(!l&&!this.loadingParts||l&&l.frag.cc!==o)&&this.syncWithAnchor(t,l?.frag)}else!this.hls.hasEnoughToStart&&c&&c.cc!==o?(c.abortRequests(),this.syncWithAnchor(t,c)):this.state===D.IDLE&&this.tick()}}getLoadPosition(){return!this.startFragRequested&&this.nextLoadPosition>=0?this.nextLoadPosition:super.getLoadPosition()}syncWithAnchor(e,t){var s;let i=((s=this.mainFragLoading)==null?void 0:s.frag)||null;if(t&&i?.cc===t.cc)return;let r=(i||e).cc,a=this.getLevelDetails(),o=this.getLoadPosition(),c=Da(a,r,o);c&&(this.log(`Syncing with main frag at ${c.start} cc ${c.cc}`),this.startFragRequested=!1,this.nextLoadPosition=c.start,this.resetLoadingState(),this.state===D.IDLE&&this.doTickIdle())}startLoad(e,t){if(!this.levels){this.startPosition=e,this.state=D.STOPPED;return}let s=this.lastCurrentTime;this.stopLoad(),this.setInterval(Wn),s>0&&e===-1?(this.log(`Override startPosition with lastCurrentTime @${s.toFixed(3)}`),e=s,this.state=D.IDLE):this.state=D.WAITING_TRACK,this.nextLoadPosition=this.lastCurrentTime=e+this.timelineOffset,this.startPosition=t?-1:e,this.tick()}doTick(){switch(this.state){case D.IDLE:this.doTickIdle();break;case D.WAITING_TRACK:{let{levels:e,trackId:t}=this,s=e?.[t],i=s?.details;if(i&&!this.waitForLive(s)){if(this.waitForCdnTuneIn(i))break;this.state=D.WAITING_INIT_PTS}break}case D.FRAG_LOADING_WAITING_RETRY:{this.checkRetryDate();break}case D.WAITING_INIT_PTS:{let e Line truncated
${this.list("video")}
${this.list("audio")}
${this.list("audiovideo")}}`}list(e){var t,s;return(t=this.queues)!=null&&t[e]||(s=this.tracks)!=null&&s[e]?`${e}: (${this.listSbInfo(e)}) ${this.listOps(e)}`:""}listSbInfo(e){var t;let s=(t=this.tracks)==null?void 0:t[e],i=s?.buffer;return i?`SourceBuffer${i.updating?" updating":""}${s.ended?" ended":""}${s.ending?" ending":""}`:"none"}listOps(e){var t;return((t=this.queues)==null?void 0:t[e].map(s=>s.label).join(", "))||""}},qn=/(avc[1234]|hvc1|hev1|dvh[1e]|vp09|av01)(?:\.[^.,]+)+/,ho="HlsJsTrackRemovedError",Xi=class extends Error{constructor(e){super(e),this.name=ho}},zi=class extends Ae{constructor(e,t){super("buffer-controller",e.logger),this.hls=void 0,this.fragmentTracker=void 0,this.details=null,this._objectUrl=null,this.operationQueue=null,this.bufferCodecEventsTotal=0,this.media=null,this.mediaSource=null,this.lastMpegAudioChunk=null,this.blockedAudioAppend=null,this.lastVideoAppendEnd=0,this.appendSource=void 0,this.transferData=void 0,this.overrides=void 0,this.appendErrors={audio:0,video:0,audiovideo:0},this.tracks={},this.sourceBuffers=[[null,null],[null,null]],this._onEndStreaming=s=>{var i;this.hls&&((i=this.mediaSource)==null?void 0:i.readyState)==="open"&&this.hls.pauseBuffering()},this._onStartStreaming=s=>{this.hls&&this.hls.resumeBuffering()},this._onMediaSourceOpen=s=>{let{media:i,mediaSource:r}=this;s&&this.log("Media source opened"),!(!i||!r)&&(r.removeEventListener("sourceopen",this._onMediaSourceOpen),i.removeEventListener("emptied",this._onMediaEmptied),this.updateDuration(),this.hls.trigger(m.MEDIA_ATTACHED,{media:i,mediaSource:r}),this.mediaSource!==null&&this.checkPendingTracks())},this._onMediaSourceClose=()=>{this.log("Media source closed")},this._onMediaSourceEnded=()=>{this.log("Media source ended")},this._onMediaEmptied=()=>{let{mediaSrc:s,_objectUrl:i}=this;s!==i&&this.error(`Media element src was set while attaching MediaSource (${i} > ${s})`)},this.hls=e,this.fragmentTracker=t,this.appendSource=Yo(ze(e.config.preferManagedMediaSource)),this.initTracks(),this.registerListeners()}hasSourceTypes(){return Object.keys(this.tracks).length>0}destroy(){this.unregisterListeners(),this.details=null,this.lastMpegAudioChunk=this.blockedAudioAppend=null,this.transferData=this.overrides=void 0,this.operationQueue&&(this.operationQueue.destroy(),this.operationQueue=null),this.hls=this.fragmentTracker=null,this._onMediaSourceOpen=this._onMediaSourceClose=null,this._onMediaSourceEnded=null,this._onStartStreaming=this._onEndStreaming=null}registerListeners(){let{hls:e}=this;e.on(m.MEDIA_ATTACHING,this.onMediaAttaching,this),e.on(m.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(m.MANIFEST_LOADING,this.onManifestLoading,this),e.on(m.MANIFEST_PARSED,this.onManifestParsed,this),e.on(m.BUFFER_RESET,this.onBufferReset,this),e.on(m.BUFFER_APPENDING,this.onBufferAppending,this),e.on(m.BUFFER_CODECS,this.onBufferCodecs,this),e.on(m.BUFFER_EOS,this.onBufferEos,this),e.on(m.BUFFER_FLUSHING,this.onBufferFlushing,this),e.on(m.LEVEL_UPDATED,this.onLevelUpdated,this),e.on(m.FRAG_PARSED,this.onFragParsed,this),e.on(m.FRAG_CHANGED,this.onFragChanged,this),e.on(m.ERROR,this.onError,this)}unregisterListeners(){let{hls:e}=this;e.off(m.MEDIA_ATTACHING,this.onMediaAttaching,this),e.off(m.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(m.MANIFEST_LOADING,this.onManifestLoading,this),e.off(m.MANIFEST_PARSED,this.onManifestParsed,this),e.off(m.BUFFER_RESET,this.onBufferReset,this),e.off(m.BUFFER_APPENDING,this.onBufferAppending,this),e.off(m.BUFFER_CODECS,this.onBufferCodecs,this),e.off(m.BUFFER_EOS,this.onBufferEos,this),e.off(m.BUFFER_FLUSHING,this.onBufferFlushing,this),e.off(m.LEVEL_UPDATED,this.onLevelUpdated,this),e.off(m.FRAG_PARSED,this.onFragParsed,this),e.off(m.FRAG_CHANGED,this.onFragChanged,this),e.off(m.ERROR,this.onError,this)}transferMedia(){let{media:e,mediaSource:t}=this;if(!e)return null;let s={};if(this.operationQueue){let r=this.isUpdating();r||this.operationQueue.removeBlockers();let a=this.isQueued();(r||a)&&this.warn(`Transfering MediaSource with${a?" operations in queue":""}${r?" updating SourceBuffer(s)":""} ${this.operationQueue}`),this.operationQueue.destroy()}let i=this.transferData;return!this.sourceBufferCount&&i&&i.mediaSource===t?se(s,i.tracks):this.sourceBuffers.forEach(r=>{let[a]=r;a&&(s[a]=se({},this.tracks[a]),this.removeBuffer(a)),r[0]=r[1]=null}),{media:e,mediaSource:t,tracks:s}}initTracks(){let e={};this.sourceBuffers=[[null,null],[null,null]],this.tracks=e,this.resetQueue(),this.resetAppendErrors(),this.lastMpegAudioChunk=this.blockedAudioAppend=null,this.lastVideoAppendEnd=0}onManifestLoading(){this.bufferCodecEventsTotal=0,this.details=null}onManifestParsed(e,t){var s;let i=2;(t.audio&&!t.video||!t.altAudio)&&(i=1),this.bufferCodecEventsTotal=i,this.log(`${i} bufferCodec event(s) expected.`),(s=this.transferData)!=null&&s.mediaSource&&this.sourceBufferCount&&i&&this.bufferCreated()}onMediaAttaching(e,t){let s=this.media=t.media;this.transferData=this.overrides=void Line truncated
required tracks: ${re(s,(c,l)=>c==="initSegment"?void 0:l)};
transfer tracks: ${re(i,(c,l)=>c==="initSegment"?void 0:l)}}`),!da(i,s)){t.mediaSource=null,t.tracks=void 0;let c=e.currentTime,l=this.details,h=Math.max(c,l?.fragments[0].start||0);if(h-c>1){this.log(`attachTransferred: waiting for playback to reach new tracks start time ${c} -> ${h}`);return}this.warn(`attachTransferred: resetting MediaSource for incompatible tracks ("${Object.keys(i)}"->"${Object.keys(s)}") start time: ${h} currentTime: ${c}`),this.onMediaDetaching(m.MEDIA_DETACHING,{}),this.onMediaAttaching(m.MEDIA_ATTACHING,t),e.currentTime=h;return}this.transferData=void 0,r.forEach(c=>{let l=c,h=i[l];if(h){let u=h.buffer;if(u){let d=this.fragmentTracker,f=h.id;if(d.hasFragments(f)||d.hasParts(f)){let p=W.getBuffered(u);d.detectEvictedFragments(l,p,f,null,!0)}let g=ti(l),y=[l,u];this.sourceBuffers[g]=y,u.updating&&this.operationQueue&&this.operationQueue.prependBlocker(l),this.trackSourceBuffer(l,h)}}}),o(),this.bufferCreated()}else this.log("attachTransferred: MediaSource w/o SourceBuffers"),o()}get mediaSourceOpenOrEnded(){var e;let t=(e=this.mediaSource)==null?void 0:e.readyState;return t==="open"||t==="ended"}onMediaDetaching(e,t){let s=!!t.transferMedia;this.transferData=this.overrides=void 0;let{media:i,mediaSource:r,_objectUrl:a}=this;if(r){if(this.log(`media source ${s?"transferring":"detaching"}`),s)this.sourceBuffers.forEach(([o])=>{o&&this.removeBuffer(o)}),this.resetQueue();else{if(this.mediaSourceOpenOrEnded){let o=r.readyState==="open";try{let c=r.sourceBuffers;for(let l=c.length;l--;)o&&c[l].abort(),r.removeSourceBuffer(c[l]);o&&r.endOfStream()}catch(c){this.warn(`onMediaDetaching: ${c.message} while calling endOfStream`)}}this.sourceBufferCount&&this.onBufferReset()}r.removeEventListener("sourceopen",this._onMediaSourceOpen),r.removeEventListener("sourceended",this._onMediaSourceEnded),r.removeEventListener("sourceclose",this._onMediaSourceClose),this.appendSource&&(r.removeEventListener("startstreaming",this._onStartStreaming),r.removeEventListener("endstreaming",this._onEndStreaming)),this.mediaSource=null,this._objectUrl=null}i&&(i.removeEventListener("emptied",this._onMediaEmptied),s||(a&&self.URL.revokeObjectURL(a),this.mediaSrc===a?(i.removeAttribute("src"),this.appendSource&&jn(i),i.load()):this.warn("media|source.src was changed by a third party - skip cleanup")),this.media=null),this.hls.trigger(m.MEDIA_DETACHED,t)}onBufferReset(){this.sourceBuffers.forEach(([e])=>{e&&this.resetBuffer(e)}),this.initTracks()}resetBuffer(e){var t;let s=(t=this.tracks[e])==null?void 0:t.buffer;if(this.removeBuffer(e),s)try{var i;(i=this.mediaSource)!=null&&i.sourceBuffers.length&&this.mediaSource.removeSourceBuffer(s)}catch(r){this.warn(`onBufferReset ${e}`,r)}delete this.tracks[e]}removeBuffer(e){this.removeBufferListeners(e),this.sourceBuffers[ti(e)]=[null,null];let t=this.tracks[e];t&&(t.buffer=void 0)}resetQueue(){this.operationQueue&&this.operationQueue.destroy(),this.operationQueue=new ji(this.tracks)}onBufferCodecs(e,t){var s;let i=this.tracks,r=Object.keys(t);this.log(`BUFFER_CODECS: "${r}" (current SB count ${this.sourceBufferCount})`);let a="audiovideo"in t&&(i.audio||i.video)||i.audiovideo&&("audio"in t||"video"in t),o=!a&&this.sourceBufferCount&&this.media&&r.some(c=>!i[c]);if(a||o){this.warn(`Unsupported transition between "${Object.keys(i)}" and "${r}" SourceBuffers`);return}r.forEach(c=>{var l,h;let u=t[c],{id:d,codec:f,levelCodec:g,container:y,metadata:p,supplemental:E}=u,T=i[c],S=(l=this.transferData)==null||(l=l.tracks)==null?void 0:l[c],v=S!=null&&S.buffer?S:T,x=v?.pendingCodec||v?.codec,_=v?.levelCodec;T||(T=i[c]={buffer:void 0,listeners:[],codec:f,supplemental:E,container:y,levelCodec:g,metadata:p,id:d});let A=ns(x,_),b=A?.replace(qn,"$1"),R=ns(f,g),I=(h=R)==null?void 0:h.replace(qn,"$1");R&&A&&b!==I&&(c.slice(0,5)==="audio"&&(R=gs(R,this.appendSource)),this.log(`switching codec ${x} to ${R}`),R!==(T.pendingCodec||T.codec)&&(T.pendingCodec=R),T.container=y,this.appendChangeType(c,y,R))}),(this.tracksReady||this.sourceBufferCount)&&(t.tracks=this.sourceBufferTracks),!this.sourceBufferCount&&(this.bufferCodecEventsTotal>1&&!this.tracks.video&&!t.video&&((s=t.audio)==null?void 0:s.id)==="main"&&(this.log("Main audio-only"),this.bufferCodecEventsTotal=1),this.mediaSourceOpenOrEnded&&this.checkPendingTracks())}get sourceBufferTracks(){return Object.keys(this.tracks).reduce((e,t)=>{let s=this.tracks[t];return e[t]={id:s.id,container:s.container,codec:s.codec,levelCodec:s.levelCodec},e},{})}appendChangeType(e,t,s){let i=`${t};codecs=${s}`,r={label:`change-type=${i}`,execute:()=>{let a=this.tracks[e];if(a){let o=a.buffer;o!=null&&o.changeType&&(this.log(`changing ${e} sourceBuffer type to ${i}`),o.changeType(i),a.codec=s,a.container=t)}this.shiftAndExecuteNext(e)},onStart:()=>{},onComplete:()=>{},onError:a=>{this.warn(`Failed to change ${e} SourceBuffer type`,a)}};this.append(r,e,this.isPending(this.tracks[e]))}blockAudio(e){var t;let s=e.start,i=s+e.duration*.05;if(((t=this.fragmen Line truncated
} (data length: ${t.byteLength})`),s.update(t)}getSelectedKeySystemFormats(){return Object.keys(this.keySystemAccessPromises).map(e=>({keySystem:e,hasMediaKeys:this.keySystemAccessPromises[e].hasMediaKeys})).filter(({hasMediaKeys:e})=>!!e).map(({keySystem:e})=>qs(e)).filter(e=>!!e)}getKeySystemAccess(e){return this.getKeySystemSelectionPromise(e).then(({keySystem:t,mediaKeys:s})=>this.attemptSetMediaKeys(t,s))}selectKeySystem(e){return new Promise((t,s)=>{this.getKeySystemSelectionPromise(e).then(({keySystem:i})=>{let r=qs(i);r?t(r):s(new Error(`Unable to find format for key-system "${i}"`))}).catch(s)})}selectKeySystemFormat(e){let t=Object.keys(e.levelkeys||{});return this.keyFormatPromise||(this.log(`Selecting key-system from fragment (sn: ${e.sn} ${e.type}: ${e.level}) key formats ${t.join(", ")}`),this.keyFormatPromise=this.getKeyFormatPromise(t)),this.keyFormatPromise}getKeyFormatPromise(e){let t=_t(this.config),s=e.map(os).filter(i=>!!i&&t.indexOf(i)!==-1);return this.selectKeySystem(s)}getKeyStatus(e){let{mediaKeySessions:t}=this;for(let s=0;s<t.length;s++){let i=zh(e,t[s]);if(i)return i}}loadKey(e){let t=e.keyInfo.decryptdata,s=es(t),i=this.bannedKeyIds[s];if(i||this.getKeyStatus(t)==="internal-error"){let o=Jn(i||"internal-error",t);return this.handleError(o,e.frag),Promise.reject(o)}let r=`(keyId: ${s} format: "${t.keyFormat}" method: ${t.method} uri: ${t.uri})`;this.log(`Starting session for key ${r}`);let a=this.keyIdToKeySessionPromise[s];if(!a){let o=this.getKeySystemForKeyPromise(t).then(({keySystem:c,mediaKeys:l})=>(this.throwIfDestroyed(),this.log(`Handle encrypted media sn: ${e.frag.sn} ${e.frag.type}: ${e.frag.level} using key ${r}`),this.attemptSetMediaKeys(c,l).then(()=>(this.throwIfDestroyed(),this.createMediaKeySessionContext({keySystem:c,mediaKeys:l,decryptdata:t}))))).then(c=>{let l="cenc",h=t.pssh?t.pssh.buffer:null;return this.generateRequestWithPreferredKeySession(c,l,h,"playlist-key")});return o.catch(c=>this.handleError(c,e.frag)),this.keyIdToKeySessionPromise[s]=o,o}return a.catch(o=>{if(o instanceof Te){let c=ee({},o.data);this.getKeyStatus(t)==="internal-error"&&(c.decryptdata=t);let l=new Te(c,o.message);this.handleError(l,e.frag)}}),a}throwIfDestroyed(e="Invalid state"){if(!this.hls)throw new Error("invalid state")}handleError(e,t){if(this.hls)if(e instanceof Te){t&&(e.data.frag=t);let s=e.data.decryptdata;this.error(`${e.message}${s?` (${me(s.keyId||[])})`:""}`),this.hls.trigger(m.ERROR,e.data)}else this.error(e.message),this.hls.trigger(m.ERROR,{type:K.KEY_SYSTEM_ERROR,details:L.KEY_SYSTEM_NO_KEYS,error:e,fatal:!0})}getKeySystemForKeyPromise(e){let t=es(e),s=this.keyIdToKeySessionPromise[t];if(!s){let i=os(e.keyFormat),r=i?[i]:_t(this.config);return this.attemptKeySystemAccess(r)}return s}getKeySystemSelectionPromise(e){if(e.length||(e=_t(this.config)),e.length===0)throw new Te({type:K.KEY_SYSTEM_ERROR,details:L.KEY_SYSTEM_NO_CONFIGURED_LICENSE,fatal:!0},`Missing key-system license configuration options ${re({drmSystems:this.config.drmSystems})}`);return this.attemptKeySystemAccess(e)}attemptSetMediaKeys(e,t){if(this.mediaResolved=void 0,this.mediaKeys===t)return Promise.resolve();let s=this.setMediaKeysQueue.slice();this.log(`Setting media-keys for "${e}"`);let i=Promise.all(s).then(()=>this.media?this.media.setMediaKeys(t):new Promise((r,a)=>{this.mediaResolved=()=>{if(this.mediaResolved=void 0,!this.media)return a(new Error("Attempted to set mediaKeys without media element attached"));this.mediaKeys=t,this.media.setMediaKeys(t).then(r).catch(a)}}));return this.mediaKeys=t,this.setMediaKeysQueue.push(i),i.then(()=>{this.log(`Media-keys set for "${e}"`),s.push(i),this.setMediaKeysQueue=this.setMediaKeysQueue.filter(r=>s.indexOf(r)===-1)})}generateRequestWithPreferredKeySession(e,t,s,i){var r;let a=(r=this.config.drmSystems)==null||(r=r[e.keySystem])==null?void 0:r.generateRequest;if(a)try{let g=a.call(this.hls,t,s,e);if(!g)throw new Error("Invalid response from configured generateRequest filter");t=g.initDataType,s=g.initData?g.initData:null,e.decryptdata.pssh=s?new Uint8Array(s):null}catch(g){if(this.warn(g.message),this.hls&&this.hls.config.debug)throw g}if(s===null)return this.log(`Skipping key-session request for "${i}" (no initData)`),Promise.resolve(e);let o=es(e.decryptdata),c=e.decryptdata.uri;this.log(`Generating key-session request for "${i}" keyId: ${o} URI: ${c} (init data type: ${t} length: ${s.byteLength})`);let l=new Br,h=e._onmessage=g=>{let y=e.mediaKeysSession;if(!y){l.emit("error",new Error("invalid state"));return}let{messageType:p,message:E}=g;this.log(`"${p}" message event for session "${y.sessionId}" message size: ${E.byteLength}`),p==="license-request"||p==="license-renewal"?this.renewLicense(e,E).catch(T=>{l.eventNames().length?l.emit("error",T):this.handleError(T)}):p==="license-release"?e.keySystem===ae.FAIRPLAY&&this.updateKeySession(e,_i("acknowledged")).then(()=>this.removeSession(e)).catch(T=>this.handleError(T)):this.warn(`unhandlLine truncated
Schedule: ${c.map(y=>ke(y))} pos: ${this.timelinePos}`),h.length&&this.log(`Removed events ${h}`);let d=null,f=null;a&&(d=this.updateItem(a,this.timelinePos),this.itemsMatch(a,d)?this.playingItem=d:this.waitingItem=this.endedItem=null),this.waitingItem=this.updateItem(this.waitingItem),this.endedItem=this.updateItem(this.endedItem);let g=this.bufferingItem;if(g&&(f=this.updateItem(g,this.bufferedPos),this.itemsMatch(g,f)?this.bufferingItem=f:g.event&&(this.bufferingItem=this.playingItem,this.clearInterstitial(g.event,null))),s.forEach(y=>{y.assetList.forEach(p=>{this.clearAssetPlayer(p.identifier,null)})}),this.playerQueue.forEach(y=>{if(y.interstitial.appendInPlace){let p=y.assetItem.timelineStart,E=y.timelineOffset-p;if(E)try{y.timelineOffset=p}catch(T){Math.abs(E)>vt&&this.warn(`${T} ("${y.assetId}" ${y.timelineOffset}->${p})`)}}}),u||i){if(this.hls.trigger(m.INTERSTITIALS_UPDATED,{events:o.slice(0),schedule:c.slice(0),durations:l,removedIds:h}),this.isInterstitial(a)&&h.includes(a.event.identifier)){this.warn(`Interstitial "${a.event.identifier}" removed while playing`),this.primaryFallback(a.event);return}a&&this.trimInPlace(d,a),g&&f!==d&&this.trimInPlace(f,g),this.checkBuffer()}},this.hls=e,this.HlsPlayerClass=t,this.assetListLoader=new cr(e),this.schedule=new lr(this.onScheduleUpdate,e.logger),this.registerListeners()}registerListeners(){let e=this.hls;e&&(e.on(m.MEDIA_ATTACHING,this.onMediaAttaching,this),e.on(m.MEDIA_ATTACHED,this.onMediaAttached,this),e.on(m.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(m.MANIFEST_LOADING,this.onManifestLoading,this),e.on(m.LEVEL_UPDATED,this.onLevelUpdated,this),e.on(m.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),e.on(m.AUDIO_TRACK_UPDATED,this.onAudioTrackUpdated,this),e.on(m.SUBTITLE_TRACK_SWITCH,this.onSubtitleTrackSwitch,this),e.on(m.SUBTITLE_TRACK_UPDATED,this.onSubtitleTrackUpdated,this),e.on(m.EVENT_CUE_ENTER,this.onInterstitialCueEnter,this),e.on(m.ASSET_LIST_LOADED,this.onAssetListLoaded,this),e.on(m.BUFFER_APPENDED,this.onBufferAppended,this),e.on(m.BUFFER_FLUSHED,this.onBufferFlushed,this),e.on(m.BUFFERED_TO_END,this.onBufferedToEnd,this),e.on(m.MEDIA_ENDED,this.onMediaEnded,this),e.on(m.ERROR,this.onError,this),e.on(m.DESTROYING,this.onDestroying,this))}unregisterListeners(){let e=this.hls;e&&(e.off(m.MEDIA_ATTACHING,this.onMediaAttaching,this),e.off(m.MEDIA_ATTACHED,this.onMediaAttached,this),e.off(m.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(m.MANIFEST_LOADING,this.onManifestLoading,this),e.off(m.LEVEL_UPDATED,this.onLevelUpdated,this),e.off(m.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),e.off(m.AUDIO_TRACK_UPDATED,this.onAudioTrackUpdated,this),e.off(m.SUBTITLE_TRACK_SWITCH,this.onSubtitleTrackSwitch,this),e.off(m.SUBTITLE_TRACK_UPDATED,this.onSubtitleTrackUpdated,this),e.off(m.EVENT_CUE_ENTER,this.onInterstitialCueEnter,this),e.off(m.ASSET_LIST_LOADED,this.onAssetListLoaded,this),e.off(m.BUFFER_CODECS,this.onBufferCodecs,this),e.off(m.BUFFER_APPENDED,this.onBufferAppended,this),e.off(m.BUFFER_FLUSHED,this.onBufferFlushed,this),e.off(m.BUFFERED_TO_END,this.onBufferedToEnd,this),e.off(m.MEDIA_ENDED,this.onMediaEnded,this),e.off(m.ERROR,this.onError,this),e.off(m.DESTROYING,this.onDestroying,this))}startLoad(){this.resumeBuffering()}stopLoad(){this.pauseBuffering()}resumeBuffering(){var e;(e=this.getBufferingPlayer())==null||e.resumeBuffering()}pauseBuffering(){var e;(e=this.getBufferingPlayer())==null||e.pauseBuffering()}destroy(){this.unregisterListeners(),this.stopLoad(),this.assetListLoader&&this.assetListLoader.destroy(),this.emptyPlayerQueue(),this.clearScheduleState(),this.schedule&&this.schedule.destroy(),this.media=this.detachedData=this.mediaSelection=this.requiredTracks=this.altSelection=this.schedule=this.manager=null,this.hls=this.HlsPlayerClass=this.log=null,this.assetListLoader=null,this.onPlay=this.onPause=this.onSeeking=this.onTimeupdate=null,this.onScheduleUpdate=null}onDestroying(){let e=this.primaryMedia||this.media;e&&this.removeMediaListeners(e)}removeMediaListeners(e){xe(e,"play",this.onPlay),xe(e,"pause",this.onPause),xe(e,"seeking",this.onSeeking),xe(e,"timeupdate",this.onTimeupdate)}onMediaAttaching(e,t){let s=this.media=t.media;Se(s,"seeking",this.onSeeking),Se(s,"timeupdate",this.onTimeupdate),Se(s,"play",this.onPlay),Se(s,"pause",this.onPause)}onMediaAttached(e,t){let s=this.effectivePlayingItem,i=this.detachedData;if(this.detachedData=null,s===null)this.checkStart();else if(!i){this.clearScheduleState();let r=this.findItemIndex(s);this.setSchedulePosition(r)}}clearScheduleState(){this.log("clear schedule state"),this.playingItem=this.bufferingItem=this.waitingItem=this.endedItem=this.playingAsset=this.endedAsset=this.bufferingAsset=null}onMediaDetaching(e,t){let s=!!t.transferMedia,i=this.media;if(this.media=null,!s&&(i&&this.removeMediaListeners(i),this.detachedData)){let r=this.getBufferingPlayer();r&&(this.log(`Removing schedule state for detachedData and ${r}`),this.playingAsset=this.e Line truncated
`)),s}getTextAndFormat(){return this.rows}},Ms=class{constructor(e,t,s){this.chNr=void 0,this.outputFilter=void 0,this.mode=void 0,this.verbose=void 0,this.displayedMemory=void 0,this.nonDisplayedMemory=void 0,this.lastOutputScreen=void 0,this.currRollUpRow=void 0,this.writeScreen=void 0,this.cueStartTime=void 0,this.logger=void 0,this.chNr=e,this.outputFilter=t,this.mode=null,this.verbose=0,this.displayedMemory=new kt(s),this.nonDisplayedMemory=new kt(s),this.lastOutputScreen=new kt(s),this.currRollUpRow=this.displayedMemory.rows[we-1],this.writeScreen=this.displayedMemory,this.mode=null,this.cueStartTime=null,this.logger=s}reset(){this.mode=null,this.displayedMemory.reset(),this.nonDisplayedMemory.reset(),this.lastOutputScreen.reset(),this.outputFilter.reset(),this.currRollUpRow=this.displayedMemory.rows[we-1],this.writeScreen=this.displayedMemory,this.mode=null,this.cueStartTime=null}getHandler(){return this.outputFilter}setHandler(e){this.outputFilter=e}setPAC(e){this.writeScreen.setPAC(e)}setBkgData(e){this.writeScreen.setBkgData(e)}setMode(e){e!==this.mode&&(this.mode=e,this.logger.log(2,()=>"MODE="+e),this.mode==="MODE_POP-ON"?this.writeScreen=this.nonDisplayedMemory:(this.writeScreen=this.displayedMemory,this.writeScreen.reset()),this.mode!=="MODE_ROLL-UP"&&(this.displayedMemory.nrRollUpRows=null,this.nonDisplayedMemory.nrRollUpRows=null),this.mode=e)}insertChars(e){for(let s=0;s<e.length;s++)this.writeScreen.insertChar(e[s]);let t=this.writeScreen===this.displayedMemory?"DISP":"NON_DISP";this.logger.log(2,()=>t+": "+this.writeScreen.getDisplayText(!0)),(this.mode==="MODE_PAINT-ON"||this.mode==="MODE_ROLL-UP")&&(this.logger.log(1,()=>"DISPLAYED: "+this.displayedMemory.getDisplayText(!0)),this.outputDataUpdate())}ccRCL(){this.logger.log(2,"RCL - Resume Caption Loading"),this.setMode("MODE_POP-ON")}ccBS(){this.logger.log(2,"BS - BackSpace"),this.mode!=="MODE_TEXT"&&(this.writeScreen.backSpace(),this.writeScreen===this.displayedMemory&&this.outputDataUpdate())}ccAOF(){}ccAON(){}ccDER(){this.logger.log(2,"DER- Delete to End of Row"),this.writeScreen.clearToEndOfRow(),this.outputDataUpdate()}ccRU(e){this.logger.log(2,"RU("+e+") - Roll Up"),this.writeScreen=this.displayedMemory,this.setMode("MODE_ROLL-UP"),this.writeScreen.setRollUpRows(e)}ccFON(){this.logger.log(2,"FON - Flash On"),this.writeScreen.setPen({flash:!0})}ccRDC(){this.logger.log(2,"RDC - Resume Direct Captioning"),this.setMode("MODE_PAINT-ON")}ccTR(){this.logger.log(2,"TR"),this.setMode("MODE_TEXT")}ccRTD(){this.logger.log(2,"RTD"),this.setMode("MODE_TEXT")}ccEDM(){this.logger.log(2,"EDM - Erase Displayed Memory"),this.displayedMemory.reset(),this.outputDataUpdate(!0)}ccCR(){this.logger.log(2,"CR - Carriage Return"),this.writeScreen.rollUp(),this.outputDataUpdate(!0)}ccENM(){this.logger.log(2,"ENM - Erase Non-displayed Memory"),this.nonDisplayedMemory.reset()}ccEOC(){if(this.logger.log(2,"EOC - End Of Caption"),this.mode==="MODE_POP-ON"){let e=this.displayedMemory;this.displayedMemory=this.nonDisplayedMemory,this.nonDisplayedMemory=e,this.writeScreen=this.nonDisplayedMemory,this.logger.log(1,()=>"DISP: "+this.displayedMemory.getDisplayText())}this.outputDataUpdate(!0)}ccTO(e){this.logger.log(2,"TO("+e+") - Tab Offset"),this.writeScreen.moveCursor(e)}ccMIDROW(e){let t={flash:!1};if(t.underline=e%2===1,t.italics=e>=46,t.italics)t.foreground="white";else{let s=Math.floor(e/2)-16,i=["white","green","blue","cyan","red","yellow","magenta"];t.foreground=i[s]}this.logger.log(2,"MIDROW: "+re(t)),this.writeScreen.setPen(t)}outputDataUpdate(e=!1){let t=this.logger.time;t!==null&&this.outputFilter&&(this.cueStartTime===null&&!this.displayedMemory.isEmpty()?this.cueStartTime=t:this.displayedMemory.equals(this.lastOutputScreen)||(this.outputFilter.newCue(this.cueStartTime,t,this.lastOutputScreen),e&&this.outputFilter.dispatchCue&&this.outputFilter.dispatchCue(),this.cueStartTime=this.displayedMemory.isEmpty()?null:t),this.lastOutputScreen.copy(this.displayedMemory))}cueSplitAtTime(e){this.outputFilter&&(this.displayedMemory.isEmpty()||(this.outputFilter.newCue&&this.outputFilter.newCue(this.cueStartTime,e,this.displayedMemory),this.cueStartTime=e))}},Os=class{constructor(e,t,s){this.channels=void 0,this.currentChannel=0,this.cmdHistory=cu(),this.logger=void 0;let i=this.logger=new fr;this.channels=[null,new Ms(e,t,i),new Ms(e+1,s,i)]}getHandler(e){return this.channels[e].getHandler()}setHandler(e,t){this.channels[e].setHandler(t)}addData(e,t){this.logger.time=e;for(let s=0;s<t.length;s+=2){let i=t[s]&127,r=t[s+1]&127,a=!1,o=null;if(i===0&&r===0)continue;this.logger.log(3,()=>"["+st([t[s],t[s+1]])+"] -> ("+st([i,r])+")");let c=this.cmdHistory;if(i>=16&&i<=31){if(lu(i,r,c)){ts(null,null,c),this.logger.log(3,()=>"Repeated command ("+st([i,r])+") is dropped");continue}ts(i,r,this.cmdHistory),a=this.parseCmd(i,r),a||(a=this.parseMidrow(i,r)),a||(a=this.parsePAC(i,r)),a||(a=this.parseBackgroundAttributes(i,r))}else ts(null,null,c);if(!a&&(o=this.parseChars(i,r) Line truncated
`)}var Tr=class{constructor(){this.state="INITIAL",this.buffer="",this.decoder=new pr,this.regionList=[],this.cue=null,this.oncue=void 0,this.onparsingerror=void 0,this.onflush=void 0}parse(e){let t=this;e&&(t.buffer+=t.decoder.decode(e,{stream:!0}));function s(){let r=t.buffer,a=0;for(r=_o(r);a<r.length&&r[a]!=="\r"&&r[a]!==`
`;)++a;let o=r.slice(0,a);return r[a]==="\r"&&++a,r[a]===`
`&&++a,t.buffer=r.slice(a),o}function i(r){bo(r,function(a,o){},/:/)}try{let r="";if(t.state==="INITIAL"){if(!/\r\n|\n/.test(t.buffer))return this;r=s();let o=r.match(/^()?WEBVTT([ \t].*)?$/);if(!(o!=null&&o[0]))throw new Error("Malformed WebVTT signature.");t.state="HEADER"}let a=!1;for(;t.buffer;){if(!/\r\n|\n/.test(t.buffer))return this;switch(a?a=!1:r=s(),t.state){case"HEADER":/:/.test(r)?i(r):r||(t.state="ID");continue;case"NOTE":r||(t.state="ID");continue;case"ID":if(/^NOTE($|[ \t])/.test(r)){t.state="NOTE";break}if(!r)continue;if(t.cue=new Wr(0,0,""),t.state="CUE",r.indexOf("-->")===-1){t.cue.id=r;continue}case"CUE":if(!t.cue){t.state="BADCUE";continue}try{hu(r,t.cue,t.regionList)}catch{t.cue=null,t.state="BADCUE";continue}t.state="CUETEXT";continue;case"CUETEXT":{let o=r.indexOf("-->")!==-1;if(!r||o&&(a=!0)){t.oncue&&t.cue&&t.oncue(t.cue),t.cue=null,t.state="ID";continue}if(t.cue===null)continue;t.cue.text&&(t.cue.text+=`
`),t.cue.text+=r}continue;case"BADCUE":r||(t.state="ID")}}}catch{t.state==="CUETEXT"&&t.cue&&t.oncue&&t.oncue(t.cue),t.cue=null,t.state=t.state==="INITIAL"?"BADWEBVTT":"BADCUE"}return this}flush(){let e=this;try{if((e.cue||e.state==="HEADER")&&(e.buffer+=`
`,e.parse()),e.state==="INITIAL"||e.state==="BADWEBVTT")throw new Error("Malformed WebVTT signature.")}catch(t){e.onparsingerror&&e.onparsingerror(t)}return e.onflush&&e.onflush(),this}},uu=/\r\n|\n\r|\n|\r/g,ri=function(e,t,s=0){return e.slice(s,s+t.length)===t},du=function(e){let t=parseInt(e.slice(-3)),s=parseInt(e.slice(-6,-4)),i=parseInt(e.slice(-9,-7)),r=e.length>9?parseInt(e.substring(0,e.indexOf(":"))):0;if(!O(t)||!O(s)||!O(i)||!O(r))throw Error(`Malformed X-TIMESTAMP-MAP: Local:${e}`);return t+=1e3*s,t+=60*1e3*i,t+=3600*1e3*r,t};function qr(n,e,t){return Ct(n.toString())+Ct(e.toString())+Ct(t)}var fu=function(e,t,s){let i=e[t],r=e[i.prevCC];if(!r||!r.new&&i.new){e.ccOffset=e.presentationOffset=i.start,i.new=!1;return}for(;(a=r)!=null&&a.new;){var a;e.ccOffset+=i.start-r.start,i.new=!1,i=r,r=e[i.prevCC]}e.presentationOffset=s};function gu(n,e,t,s,i,r,a){let o=new Tr,c=be(new Uint8Array(n)).trim().replace(uu,`
`).split(`
`),l=[],h=e?Yc(e.baseTime,e.timescale):0,u="00:00.000",d=0,f=0,g,y=!0;o.oncue=function(p){let E=t[s],T=t.ccOffset,S=(d-h)/9e4;if(E!=null&&E.new&&(f!==void 0?T=t.ccOffset=E.start:fu(t,s,S)),S){if(!e){g=new Error("Missing initPTS for VTT MPEGTS");return}T=S-t.presentationOffset}let v=p.endTime-p.startTime,x=Le((p.startTime+T-f)*9e4,i*9e4)/9e4;p.startTime=Math.max(x,0),p.endTime=Math.max(x+v,0);let _=p.text.trim();p.text=decodeURIComponent(encodeURIComponent(_)),p.id||(p.id=qr(p.startTime,p.endTime,_)),p.endTime>0&&l.push(p)},o.onparsingerror=function(p){g=p},o.onflush=function(){if(g){a(g);return}r(l)},c.forEach(p=>{if(y)if(ri(p,"X-TIMESTAMP-MAP=")){y=!1,p.slice(16).split(",").forEach(E=>{ri(E,"LOCAL:")?u=E.slice(6):ri(E,"MPEGTS:")&&(d=parseInt(E.slice(7)))});try{f=du(u)/1e3}catch(E){g=E}return}else p===""&&(y=!1);o.parse(p+`
`)}),o.flush()}var ni="stpp.ttml.im1t",Do=/^(\d{2,}):(\d{2}):(\d{2}):(\d{2})\.?(\d+)?$/,Po=/^(\d*(?:\.\d*)?)(h|m|s|ms|f|t)$/,mu={left:"start",center:"center",right:"end",start:"start",end:"end"};function ia(n,e,t,s){let i=X(new Uint8Array(n),["mdat"]);if(i.length===0){s(new Error("Could not parse IMSC1 mdat"));return}let r=i.map(o=>be(o)),a=Hc(e.baseTime,1,e.timescale);try{r.forEach(o=>t(pu(o,a)))}catch(o){s(o)}}function pu(n,e){let i=new DOMParser().parseFromString(n,"text/xml").getElementsByTagName("tt")[0];if(!i)throw new Error("Invalid ttml");let r={frameRate:30,subFrameRate:1,frameRateMultiplier:0,tickRate:0},a=Object.keys(r).reduce((u,d)=>(u[d]=i.getAttribute(`ttp:${d}`)||r[d],u),{}),o=i.getAttribute("xml:space")!=="preserve",c=ra(ai(i,"styling","style")),l=ra(ai(i,"layout","region")),h=ai(i,"body","[begin]");return[].map.call(h,u=>{let d=Co(u,o);if(!d||!u.hasAttribute("begin"))return null;let f=li(u.getAttribute("begin"),a),g=li(u.getAttribute("dur"),a),y=li(u.getAttribute("end"),a);if(f===null)throw na(u);if(y===null){if(g===null)throw na(u);y=f+g}let p=new Wr(f-e,y-e,d);p.id=qr(p.startTime,p.endTime,p.text);let E=l[u.getAttribute("region")],T=c[u.getAttribute("style")],S=yu(E,T,c),{textAlign:v}=S;if(v){let x=mu[v];x&&(p.lineAlign=x),p.align=v}return se(p,S),p}).filter(u=>u!==null)}function ai(n,e,t){let s=n.getElementsByTagName(e)[0];return s?[].slice.call(s.querySelectorAll(t)):[]}function ra(n){return n.reduce((e,t)=>{let s=t.getAttribute("xml:id");return s&&(e[s]=t),e},{})}function Co(n,e){return[].slice.call(n.childNodes).reduce((t,s,i)=>{var r;return s.nodeName==="br"&&i?t+`
`:(r=s.childNodes)!=null&&r.length?Co(s,e):e?t+s.textContent.trim().replace(/\s+/g," "):t+s.textContent},"")}function yu(n,e,t){let s="http://www.w3.org/ns/ttml#styling",i=null,r=["displayAlign","textAlign","color","backgroundColor","fontSize","fontFamily"],a=n!=null&&n.hasAttribute("style")?n.getAttribute("style"):null;return a&&t.hasOwnProperty(a)&&(i=t[a]),r.reduce((o,c)=>{let l=oi(e,s,c)||oi(n,s,c)||oi(i,s,c);return l&&(o[c]=l),o},{})}function oi(n,e,t){return n&&n.hasAttributeNS(e,t)?n.getAttributeNS(e,t):null}function na(n){return new Error(`Could not parse ttml timestamp ${n}`)}function li(n,e){if(!n)return null;let t=Ro(n);return t===null&&(Do.test(n)?t=Eu(n,e):Po.test(n)&&(t=Tu(n,e))),t}function Eu(n,e){let t=Do.exec(n),s=(t[4]|0)+(t[5]|0)/e.subFrameRate;return(t[1]|0)*3600+(t[2]|0)*60+(t[3]|0)+s/e.frameRate}function Tu(n,e){let t=Po.exec(n),s=Number(t[1]);switch(t[2]){case"h":return s*3600;case"m":return s*60;case"ms":return s*1e3;case"f":return s/e.frameRate;case"t":return s/e.tickRate}return s}var yt=class{constructor(e,t){this.timelineController=void 0,this.cueRanges=[],this.trackName=void 0,this.startTime=null,this.endTime=null,this.screen=null,this.timelineController=e,this.trackName=t}dispatchCue(){this.startTime!==null&&(this.timelineController.addCues(this.trackName,this.startTime,this.endTime,this.screen,this.cueRanges),this.startTime=null)}newCue(e,t,s){(this.startTime===null||this.startTime>e)&&(this.startTime=e),this.endTime=t,this.screen=s,this.timelineController.createCaptionsTrack(this.trackName)}reset(){this.cueRanges=[],this.startTime=null}},Sr=class{constructor(e){this.hls=void 0,this.media=null,this.config=void 0,this.enabled=!0,this.Cues=void 0,this.textTracks=[],this.tracks=[],this.initPTS=[],this.unparsedVttFrags=[],this.captionsTracks={},this.nonNativeCaptionsTracks={},this.cea608Parser1=void 0,this.cea608Parser2=void 0,this.lastCc=-1,this.lastSn=-1,this.lastPartIndex=-1,this.prevCC=-1,this.vttCCs=oa(),this.captionsProperties=void 0,this.hls=e,this.config=e.config,this.Cues=e.config.cueHandler,this.captionsProperties={textTrack1:{label:this.config.captionsTextTrack1Label,languageCode:this.config.captionsTextTrack1LanguageCode},textTrack2:{label:this.config.captionsTextTrack2Label,languageCode:this.config.captionsTextTrack2LanguageCode},textTrack3:{label:this.config.captionsTextTrack3Label,languageCode:this.config.captionsTextTrack3LanguageCode},textTrack4:{label:this.config.captionsTextTrack4Label,languageCode:this.config.captionsTextTrack4LanguageCode}},e.on(m.MEDIA_ATTACHING,this.onMediaAttaching,this),e.on(m.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(m.MANIFEST_LOADING,this.onManifestLoading,this),e.on(m.MANIFEST_LOADED,this.onManifestLoaded,this),e.on(m.SUBTITLE_TRACKS_UPDATED,this.onSubtitleTracksUpdated,this),e.on(m.FRAG_LOADING,this.onFragLoading,this),e.on(m.FRAG_LOADED,this.onFragLoaded,this),e.on(m.FRAG_PARSING_USERDATA,this.onFragParsingUserdata,this),e.on(m.FRAG_DECRYPTED,this.onFragDecrypted,this),e.on(m.INIT_PTS_FOUND,this.onInitPtsFound,this),e.on(m.SUBTITLE_TRACKS_CLEARED,this.onSubtitleTracksCleared,this),e.on(m.BUFFER_FLUSHING,this.onBufferFlushing,this)}destroy(){let{hls:e}=this;e.off(m.MEDIA_ATTACHING,this.onMediaAttaching,this),e.off(m.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(m.MANIFEST_LOADING,this.onManifestLoading,this),e.off(m.MANIFEST_LOADED,this.onManifestLoaded,this),e.off(m.SUBTITLE_TRACKS_UPDATED,this.onSubtitleTracksUpdated,this),e.off(m.FRAG_LOADING,this.onFragLoading,this),e.off(m.FRAG_LOADED,this.onFragLoaded,this),e.off(m.FRAG_PARSING_USERDATA,this.onFragParsingUserdata,this),e.off(m.FRAG_DECRYPTED,this.onFragDecrypted,this),e.off(m.INIT_PTS_FOUND,this.onInitPtsFound,this),e.off(m.SUBTITLE_TRACKS_CLEARED,this.onSubtitleTracksCleared,this),e.off(m.BUFFER_FLUSHING,this.onBufferFlushing,this),this.hls=this.config=this.media=null,this.cea608Parser1=this.cea608Parser2=void 0}initCea608Parsers(){let e=new yt(this,"textTrack1"),t=new yt(this,"textTrack2"),s=new yt(this,"textTrack3"),i=new yt(this,"textTrack4");this.cea608Parser1=new Os(1,e,t),this.cea608Parser2=new Os(3,s,i)}addCues(e,t,s,i,r){let a=!1;for(let o=r.length;o--;){let c=r[o],l=Su(c[0],c[1],t,s);if(l>=0&&(c[0]=Math.min(c[0],t),c[1]=Math.max(c[1],s),a=!0,l/(s-t)>.5))return}if(a||r.push([t,s]),this.config.renderTextTracksNatively){let o=this.captionsTracks[e];this.Cues.newCue(o,t,s,i)}else{let o=this.Cues.newCue(null,t,s,i);this.hls.trigger(m.CUES_PARSED,{type:"captions",cues:o,track:e})}}onInitPtsFound(e,{frag:t,id:s,initPTS:i,timescale:r,trackId:a}){let{unparsedVttFrags:o}=this;s===N.MAIN&&(this.initPTS[t.cc]={baseTime:i,timescale:r,trackId:a}),o.length&&(this.unparsedVttFrags=[],o.forEach(c=>{this.initPTS[c.frag.cc]?this.onFragLoaded(m.FRAG_LOADED,c):this.hls.trigger(m.SUBTITLE_FRAG_PROCESSED,{success:!1,frag:c.frag,error:new Error("Subtitle discontinuity domain does not match main")})}))}getExistingTrack(e,t){let{media:s}=this;if(s)for(let i=0;i<s.textTracks.length;i+ Line truncated
+12 -3
View File
@@ -11,19 +11,28 @@
{
"source": "/sw.min.js",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, must-revalidate" }
{
"key": "Cache-Control",
"value": "no-cache, must-revalidate"
}
]
},
{
"source": "/",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, must-revalidate" }
{
"key": "Cache-Control",
"value": "no-cache, must-revalidate"
}
]
},
{
"source": "/(.*\\.html)",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, must-revalidate" }
{
"key": "Cache-Control",
"value": "no-cache, must-revalidate"
}
]
}
]