mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Compare commits
32
Commits
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
@@ -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.
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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/")) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 : "",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
Generated
+15
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-3357
File diff suppressed because it is too large.
Load diff
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
Vendored
+6
@@ -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";
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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 }) => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
""
|
||||
);
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>`),
|
||||
""><script>alert(1)</script>"
|
||||
);
|
||||
assert.equal(escapeHtmlAttribute("a & b"), "a & b");
|
||||
assert.equal(escapeHtmlAttribute("it's"), "it'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, /<script>/);
|
||||
});
|
||||
|
||||
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, /" onload="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'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"
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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"'));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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), "");
|
||||
});
|
||||
@@ -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: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"contrast": "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 truncated
|
||||
"default": "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 truncated
|
||||
"light": "iVBORw0KGgoAAAANSUhEUgAABQwAAAXVCAIAAADPSQWyAAAQAElEQVR4nOz9S7rsqs4FCkr/txqblexAFrOahWyy7tkzImwekpB42DhC4+yzpg1CYASyhsGOf/+v//f/BwKBwG4g24kpQxKn9ERNBE07CX99WX0YK31thZe31Q3szGtn7w/kT1pdYpeEkU5a2r2oHvUoxAXtnajSNBOJEyOh7JFeH6Snn39fSfRyrId7PU7/xOiTd6S89XyO6e+oUPvKJTqFD+VvmaP4IQ1pytmkM4M/K8HPIHwfHkMCj5O/rHMS4X/JmfAn5S3zzv0bXXjoAHxL/KVhKvlRiOeQPFOSio7qzrZg1mbA8rKOKo4WaUBD2uM9aCDwMPz//3//36bMPwgEAoFAIBAIBAKBQCDwhyDJgcDPgyZIzK5xkpaOinCSnkDgA4qFoq9AuIFAIBD4HQRJDgQCa/GwyDICYRbB81h8V7eEkZdgE5dC6un0ujA7iKEVCAQeh/+DQCAQqDHnneH7QEF33aBmttir0dcNJC+jBgJLEOMrEAgEJiJIciAQCAR+HSSe7ICgP4EM3gFhl4+hFggEAi/EdutA4BdxRSS05pVhreh+8d3jIs6BXZGP31B5xQXErtMfR3DQHDEhAoHAtoiV5EDgN3F9sLZyA3dsrp4HanbnrF8h2xkLLuVJvTP4y2y79p7dT8y/gv3NT92Zk+qlRn4gEAhciVhJDgQCbzz49eP98AWBXXoJ5tUe8olvho0Xk2PJ7WsxzVcEmwwEAoF5iJXkQCCwENSV5dAaDPkSUH1e/yeLPwiknNnLzZMdLxaYgz27v6tVJBzvghjpgUBgA8RKciDwJFwUPKzcGT0TsT3vQjiXMgsjPHAV9H3B4nV/5drurIsiApzdO7GY7gBNkHCJOqwThgwEAk9ArCQHAr+NqbGUTK+pkd8B4dNdwZBvQ9d7zBvC19YYcIEWunyoLytgRPRhIBAwIkhyILA5VnxBZp7ODdac+Q2/gQVod7WW/fU8efIDp6sg78fYb1v4iOqn+Qdre6cw8Gs7h9wZc9SPqpnYe9Q4DwQC9yNIciCwH+64X15Z59jbyPT+C+nfwNWg5soxKa8yPwRm9hsInIjxkmAxIQ4EAoFViHeSA4GvwIIn8H6VLeY6Kyr6FYY89/pueguQ2PonXtqq68pfnCS2Iuqunrb6xnWm9mUbZHJ8Cumv7PTm0m1DWYB5Z073oCdjmrUsDLSlURf25M2ua1x8LrYbsoFAwIQgyYFAYBVmk9nfWXoYiYpbOs9wbULgxlNgXdRawN6ElTy5pZ6yz3tFOByYiut93u942SZiNgcCv40gyYHAY7Bt9GJq2CzGPJt57wVaf1knr6W5VBVc6mZyZgdVH6vFQpen6bsW01uz+OEF+DA+ryxvnJNVOPbx9+DP7sFcA4HARYh3kgOBnbEiiJqnk7TEWEZ2g669Rvqr8fXfvP36nYrmvK1M08cJGbS6qhxu30WDhKYwOVpgkrdih1pvAzbwNUGfO3Bhp2V77MNYgcB3IlaSA4Hnw3OPpuaJ845PlsRZjHk2894Fbyp2dwP+W6GZuQc7xYW/oUpL1prUvdfvhXSavD6vNAXmg9kNcO4ih946/8oSLmzxAZSzhvQG5uOCaXIVvuhSAoFAiiDJgcBmuPJDK23MYMwT8MVBa88PchlLdEVuqyK+tM1oF1WAuooFO8ktWg08mUY/iwXrzDRV8wi3/mJQR06fXE9x3N/dmocoFR+kMz+p+98Dwxi2gcDPI0hyIPBTIEeqTxOJOa10a03fw5Rn9HdvEbQUWBwgjlgyC3ob2cs/BO3IexIEbjF2edessC9SMt33XOQCZkKvvrULZeq34SXQoi0L6/amQCAQ2BdBkgOBnSBvhrYV6a5LTewHHf/MVvogkHLmLDwBhsiMkiXA7aK4ukO4/cHMyXi9n8XkllpTF28eH8/uR3rGN5fInTFzhl7i2a52n6hlfA1PjLXnQOALESQ5EHgAZsU1Mi/urMFO6rsqeBodfoOaCc7yk9HgmakcXrW4PADSePLKijSZtiyN0oTVBON8UgJpa8FbbdYlG44napyr83HJXKWB3BHNoqRhC7Z728od2JuTf80Tg0DgWxBftw4EfgfEHl5Q5yhDpmfuuaas5fYS14PaG/G37ngSn9csaPaThuA4sq6kMq1bWWAvjLMzFE900a/B/IfEgUDgXgRJDgT2xPyb6soNgywpuWbFYxP8MYj3Lyq1yS4J/92LR/PkEmsa+2GJrcVFW+2jnPMOnG3ua/bTnnbZrWnXdyUwr9rw2jBv2s6vAKJBxlwLCqWuRLDdQOB3ECQ5ENgGHbffsTi1H2sjBZLOtgxQyLhcvg8ZVmDgyfs2v7lvdkktkoyLJ2+NVf7iq/gGVQfTsWYBFpVTc41DTUNDigNDBiD1NBAI/BSCJAcCAQZWJjCuxaZhS3LmaNSDgi1+iZseQ5Uz2Nnqmrq/EsQcOcsn8/peTHywspe9sZWJshjyx1bqyhVBTZYaQrsiKHQg8N0Y+3DXlR4ivmcQ+FUsnWckntwFJny+vF3+CsmV/DwQ890m2vnb18tb1rr6S3pnRxNY8eS2rwQN5KpAS2lq6iiEWvu3/8yMjPCg8ZvsXhTaGTEpAoGd8O8xQZzeznArgadj8UzsUu8pJO847qm6nyGTctaA14382DoCMez4Ok7ajwVxJ230e0bXNEOuxVo/J7dTP/6HZ87ok/zKNBiP1+g1mf9+ZdjSCRM2V3tUPCC+6x3EQYoDgZ3xLT8BtTQUDgQCV8K9FZOMaSY1KGc5NX0f0n22737a1Z1uEX1+bwj8Q8H9zMns19VkrTjf3djZMl+4OmwPFbxsEXjeyGU0BecNBL4KP/k7yc1bTiBwM4g50qR8OmeKTtZhu/CmlqHC//3T5wa+lRuzeD9SsO68vAFJxBrB667YbTH5XsxivB89eKwgq5ob1dpbxW6ndpqWKccRb5OKrRCjPBB4Gn6SJOvovkWF+wvsiieRtzF+O+tKf4ruBjowOeIdUqdsgVgIbvt9YAgSG8XExmQobhE7l4ubtTY12sB/xNo7cmKkBQKBqxAkeR4irA5cie7xJr3uu9MAjsn0FBxb43HnBcFFDZuk9mc4pnaht3TCc/0MQ1r1l41bMmc+vl+mYPSDXBzLUywOVPEs0bwEvWKH9hbvaAQCgT0QPwEVCOyLTW+XGzaLZi4jB74GvzQk6M7LjbnnxBwyhgO5iix+/kGG/fL/5TKoV2Pmzajmpuk7PGaiCRKBQGAjzFxJXjT9Y3NNIHAl5k7kS6ICvLrCQInw08O4Z9P0ELLl3+614J9ZSXeDXfjNE2sRbO6ervdRJ3mYfNIBu/xpymwxXR6Wia7P/jZpFIR3G2sx/AOBbfFv/4iy44MRgUCgiS3nPna269wKeKUnCEIe2ApxGxzAHbOZZ8Fo+/QgV7j+UleZkuymLhRkrzQfxcHzpWvkz4pl6nM5WWKwyJTFUm5PdhmcNxD4HrhWku+NCBt+R/9MRSDwOJA749eBV/dNrGA/Bb9wE4gb3R6Y7YYymppQ3FQCqbGeXIrUHDhbaIa0mrcMtK4Lz8KfI/OYzFacyyz++KhAFBhATKZAIPBHkp8S2zXbiX0lwxkGdgOZkjZHd6iIN18t6xFIcxSUFQu2/CPY59bx/JvYH0nbcA3ONZnREGxoG6Fb+6h7WiDrRO7TXAeTfh8mfBkOqixXnhxhuQL8WT7GUyRLkdUxafo42Xo+1AYKBAK74pu+bt3pe6ijTCCwDNM51rafsJbRy5M/8Rb10nOUsxhQmZmw5R+hyqicBX4OIzT3Xor8nPmKxQeolWVkdcc1z5hzngxZx1C9cwazOpNiRSqW0lV76z8F61bKCVIoKNzPTW34eCgQCMBX/wRUz/5r468bBAIrcUe8tqxON99N47/uSq+ZsUUtxISMdPzzhXiAX3zvAF3U0rgxfEd8P2WCdvgsrgi7wOtziek26Sz5P/6L0ke7MHvql8lwz/2k5V+RISeH+TIyimyW9eQ1hS7LwD0YmwnBkwOBDfGbv5PMfCnDXiAcWeALsD1v4+KyzSGGkqhexrMoNLIJ4RU5XNArl3X8JRVdTxSun3wIzbeH1T3Z+Vm9AlwvLL849nsx+ZP94cfpWSaf82T4jyq3baMxZNejqkr45NXAcWlo6rPJXY8gx4HAxvhNklzDwYKl+1cgMA5ypF7YgItxRmmPpcpFmragnG/W3hGoJO/s+pY2MHz+EnwLZ2CYL3qmOMpf6mrS7DSNo9YoLBScK8Z1pblZiEuEWphjsdIysshjsSyIXC5TywDs9DsQCHwxgiTX6GHBwZwD41jBkMdK45iCgeJZlIaj13E76tiuuiDa6DJRz3mEZ1vc0jWa454BCTn8DYiUtV20oNJMBkeV+cXkWgrkp3uoNqk8SqYiZkc8xcX6APSarDK+3C70POT5lIlF5UBgN0wiyYtCu40cRs99u9kr4RADB25fQx7DAoKXRWnP58kFqnWZlNTRlheLz3FYOHFRiVU/RyYX3bd7v/ROdfckw+r1YHbh9zg6/snWpVHccV3ofO24flPj1+5rlicf68nQoMr15VRHAkP+tPfMyjmzxLQLhVozkE+vWTkiLy8mBgKBn8G/rSNP46PU60BzG/FdUX/guUB1bJNDfEBUUYA/NlmOr3TDDRfOceGH0mNYueJ7f598dQj/zPVkluu6/Re2XktGxx7stEkfnox/1JjOptbryQWDh8aXFVLJ9BTZXHaj9eu4WkY+i2KhozgpdaHQpB4EWw4EfhLP3249mbeONCL8aGAqvoAXTqG4byVv3viFbFnsoDMO/ORPv3psnMP+jo1fwVrWbM8ysr0NP7GM/J23SJd/yxaBQXsIyS0jlzKpwtcBu5h8/gRUXs0hLPJkKKmy4zplQpt8z/oUZjkzGCZ1TYbvHmcUCyeBwHfge99JVpzNKg8qPfsNBH4AfCAGzhhSUv73L72Line 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
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+8269
File diff suppressed because it is too large.
Load diff
+11772
File diff suppressed because it is too large.
Load diff
+494
-472
File diff suppressed because it is too large.
Load diff
Vendored
+507
-248
File diff suppressed because it is too large.
Load diff
Vendored
+1795
-899
File diff suppressed because it is too large.
Load diff
Vendored
+28
@@ -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.
|
||||
Vendored
+40
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user