Support legacy infohash playback

This commit is contained in:
thePR0M3TH3AN
2025-09-23 13:29:05 -04:00
parent 3e7eab1c92
commit 985acf6456
7 changed files with 327 additions and 33 deletions

View File

@@ -6,6 +6,7 @@ import { torrentClient } from "./webtorrent.js";
import { isDevMode } from "./config.js";
import { isWhitelistEnabled } from "./config.js";
import { normalizeAndAugmentMagnet } from "./magnetUtils.js";
import { deriveTorrentPlaybackConfig } from "./playbackUtils.js";
import {
initialWhitelist,
initialBlacklist,
@@ -37,6 +38,10 @@ function isValidMagnetUri(magnet) {
return false;
}
if (/^[0-9a-f]{40}$/i.test(trimmed)) {
return true;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol.toLowerCase() !== "magnet:") {
@@ -1438,9 +1443,12 @@ class bitvidApp {
const trimmedUrl = typeof video.url === "string" ? video.url.trim() : "";
const trimmedMagnet =
typeof video.magnet === "string" ? video.magnet.trim() : "";
const magnetSupported = isValidMagnetUri(trimmedMagnet);
const magnetProvided = trimmedMagnet.length > 0;
const encodedMagnet = encodeDataValue(trimmedMagnet);
const legacyInfoHash =
typeof video.infoHash === "string" ? video.infoHash.trim() : "";
const magnetCandidate = trimmedMagnet || legacyInfoHash;
const magnetSupported = isValidMagnetUri(magnetCandidate);
const magnetProvided = magnetCandidate.length > 0;
const encodedMagnet = encodeDataValue(magnetCandidate);
const encodedUrl = encodeDataValue(trimmedUrl);
const showUnsupportedTorrentBadge =
!trimmedUrl && magnetProvided && !magnetSupported;
@@ -2078,34 +2086,37 @@ class bitvidApp {
async playVideoWithFallback({
url = "",
magnet = "",
infoHash = "",
title = "",
description = "",
} = {}) {
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
const magnetSupported = isValidMagnetUri(trimmedMagnet);
const sanitizedMagnet = magnetSupported ? trimmedMagnet : "";
const magnetNormalization = sanitizedMagnet
? normalizeAndAugmentMagnet(sanitizedMagnet, {
webSeed: sanitizedUrl ? [sanitizedUrl] : [],
logger: (message) => this.log(message),
})
: { magnet: "", didChange: false };
const magnetForPlayback = sanitizedMagnet
? magnetNormalization.magnet || sanitizedMagnet
const trimmedInfoHash =
typeof infoHash === "string" ? infoHash.trim().toLowerCase() : "";
const playbackConfig = deriveTorrentPlaybackConfig({
magnet: trimmedMagnet,
infoHash: trimmedInfoHash,
url: sanitizedUrl,
logger: (message) => this.log(message),
});
const magnetIsUsable = isValidMagnetUri(playbackConfig.magnet);
const magnetForPlayback = magnetIsUsable ? playbackConfig.magnet : "";
const fallbackMagnet = magnetIsUsable
? playbackConfig.fallbackMagnet
: "";
const fallbackMagnet = magnetNormalization.didChange
? sanitizedMagnet
: "";
const magnetProvided = trimmedMagnet.length > 0;
const magnetProvided = playbackConfig.provided;
if (this.currentVideo) {
this.currentVideo.magnet = magnetForPlayback;
this.currentVideo.normalizedMagnet = magnetForPlayback;
this.currentVideo.normalizedMagnetFallback = fallbackMagnet;
if (!magnetForPlayback) {
this.currentVideo.torrentSupported = false;
if (playbackConfig.infoHash && !this.currentVideo.legacyInfoHash) {
this.currentVideo.legacyInfoHash = playbackConfig.infoHash;
}
this.currentVideo.torrentSupported = !!magnetForPlayback;
}
this.currentMagnetUri = magnetForPlayback || null;
this.setCopyMagnetState(!!magnetForPlayback);
@@ -2132,7 +2143,7 @@ class bitvidApp {
this.resetTorrentStats();
if (magnetForPlayback) {
const label = magnetNormalization.didChange
const label = playbackConfig.didMutate
? "[playVideoWithFallback] Using normalized magnet URI:"
: "[playVideoWithFallback] Using magnet URI:";
this.log(`${label} ${magnetForPlayback}`);
@@ -2153,7 +2164,7 @@ class bitvidApp {
fallbackMagnet,
});
} else {
const message = magnetProvided && !magnetSupported
const message = magnetProvided && !magnetForPlayback
? UNSUPPORTED_BTITH_MESSAGE
: "No playable source available.";
if (this.modalStatus) {
@@ -2213,15 +2224,19 @@ class bitvidApp {
const trimmedUrl = typeof video.url === "string" ? video.url.trim() : "";
const rawMagnet =
typeof video.magnet === "string" ? video.magnet.trim() : "";
const magnetSupported = isValidMagnetUri(rawMagnet);
const sanitizedMagnet = magnetSupported ? rawMagnet : "";
const legacyInfoHash =
typeof video.infoHash === "string" ? video.infoHash.trim().toLowerCase() : "";
const magnetCandidate = rawMagnet || legacyInfoHash;
const magnetSupported = isValidMagnetUri(magnetCandidate);
const sanitizedMagnet = magnetSupported ? magnetCandidate : "";
this.currentVideo = {
...video,
url: trimmedUrl,
magnet: sanitizedMagnet,
originalMagnet: rawMagnet,
originalMagnet: magnetCandidate,
torrentSupported: magnetSupported,
legacyInfoHash: video.legacyInfoHash || legacyInfoHash,
};
this.currentMagnetUri = sanitizedMagnet || null;
@@ -2282,7 +2297,8 @@ class bitvidApp {
await this.playVideoWithFallback({
url: trimmedUrl,
magnet: rawMagnet,
magnet: magnetCandidate,
infoHash: legacyInfoHash,
title: video.title,
description: video.description,
});

View File

@@ -341,8 +341,13 @@ async function loadUserVideos(pubkey) {
"duration-300"
);
const trimmedMagnet =
typeof video.magnet === "string" ? video.magnet.trim() : "";
const legacyInfoHash =
typeof video.infoHash === "string" ? video.infoHash.trim() : "";
const magnetCandidate = trimmedMagnet || legacyInfoHash;
const encodedUrl = encodeDataValue(video.url);
const encodedMagnet = encodeDataValue(video.magnet);
const encodedMagnet = encodeDataValue(magnetCandidate);
cardEl.innerHTML = `
<div

View File

@@ -91,15 +91,26 @@ function inferMimeTypeFromUrl(url) {
* CHANGED: skip if version <2
*/
function convertEventToVideo(event) {
const { parsedContent, parseError, title, url, magnet, version } =
parseVideoEventPayload(event);
const {
parsedContent,
parseError,
title,
url,
magnet,
infoHash,
version,
} = parseVideoEventPayload(event);
if (!title) {
const reason = parseError ? "missing title (json parse error)" : "missing title";
return { id: event.id, invalid: true, reason };
}
if (!url && !magnet) {
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
const trimmedInfoHash = typeof infoHash === "string" ? infoHash.trim() : "";
const playbackMagnet = trimmedMagnet || trimmedInfoHash;
if (!url && !playbackMagnet) {
return {
id: event.id,
invalid: true,
@@ -114,7 +125,9 @@ function convertEventToVideo(event) {
isPrivate: parsedContent.isPrivate ?? false,
title,
url,
magnet,
magnet: playbackMagnet,
rawMagnet: trimmedMagnet,
infoHash: trimmedInfoHash,
thumbnail: parsedContent.thumbnail ?? "",
description: parsedContent.description ?? "",
mode: parsedContent.mode ?? "live",
@@ -141,6 +154,8 @@ function getActiveKey(video) {
return `LEGACY:${video.id}`;
}
export { convertEventToVideo };
class NostrClient {
constructor() {
this.pool = null;

72
js/playbackUtils.js Normal file
View File

@@ -0,0 +1,72 @@
// js/playbackUtils.js
import { normalizeAndAugmentMagnet } from "./magnetUtils.js";
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
const MAGNET_URI = /^magnet:\?/i;
export function deriveTorrentPlaybackConfig({
magnet = "",
infoHash = "",
url = "",
logger,
appProtocol,
} = {}) {
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
const trimmedInfoHash =
typeof infoHash === "string" ? infoHash.trim().toLowerCase() : "";
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
const magnetIsUri = MAGNET_URI.test(trimmedMagnet);
const magnetLooksLikeInfoHash = HEX_INFO_HASH.test(trimmedMagnet);
const resolvedInfoHash = trimmedInfoHash || (magnetLooksLikeInfoHash
? trimmedMagnet.toLowerCase()
: "");
const normalizationInput = magnetIsUri ? trimmedMagnet : resolvedInfoHash;
const provided = Boolean(trimmedMagnet || trimmedInfoHash);
if (!normalizationInput) {
return {
magnet: "",
fallbackMagnet: "",
provided,
usedInfoHash: false,
originalInput: "",
didMutate: false,
infoHash: resolvedInfoHash,
};
}
const normalization = normalizeAndAugmentMagnet(normalizationInput, {
webSeed: sanitizedUrl ? [sanitizedUrl] : [],
logger,
appProtocol,
});
let normalizedMagnet = normalization.magnet;
if (!normalizedMagnet || !MAGNET_URI.test(normalizedMagnet)) {
if (magnetIsUri) {
normalizedMagnet = normalizationInput;
} else if (resolvedInfoHash) {
normalizedMagnet = `magnet:?xt=urn:btih:${resolvedInfoHash}`;
} else {
normalizedMagnet = "";
}
}
const usedInfoHash = !magnetIsUri && Boolean(resolvedInfoHash);
const fallbackMagnet = magnetIsUri && normalization.didChange
? normalizationInput
: "";
return {
magnet: normalizedMagnet,
fallbackMagnet,
provided,
usedInfoHash,
originalInput: normalizationInput,
didMutate: normalization.didChange || usedInfoHash,
infoHash: resolvedInfoHash,
};
}

View File

@@ -4,6 +4,51 @@
* Extracts normalized fields from a Bitvid video event while
* tolerating legacy payloads that may omit version >= 2 metadata.
*/
const MAGNET_URI_PATTERN = /^magnet:\?/i;
const HEX_INFO_HASH_PATTERN = /\b[0-9a-f]{40}\b/gi;
function extractInfoHashesFromString(value, pushInfoHash) {
if (typeof value !== "string") {
return;
}
const trimmed = value.trim();
if (!trimmed) {
return;
}
const matches = trimmed.match(HEX_INFO_HASH_PATTERN);
if (!matches) {
return;
}
for (const match of matches) {
const normalized = match.toLowerCase();
pushInfoHash(normalized);
}
}
function traverseForInfoHashes(value, pushInfoHash) {
if (!value) {
return;
}
if (typeof value === "string") {
extractInfoHashesFromString(value, pushInfoHash);
return;
}
if (Array.isArray(value)) {
for (const item of value) {
traverseForInfoHashes(item, pushInfoHash);
}
return;
}
if (typeof value === "object") {
for (const nested of Object.values(value)) {
traverseForInfoHashes(nested, pushInfoHash);
}
}
}
export function parseVideoEventPayload(event = {}) {
const rawContent = typeof event.content === "string" ? event.content : "";
@@ -29,6 +74,7 @@ export function parseVideoEventPayload(event = {}) {
: "";
const magnetCandidates = [];
const infoHashCandidates = [];
const urlCandidates = [];
const pushUnique = (arr, value) => {
@@ -38,14 +84,48 @@ export function parseVideoEventPayload(event = {}) {
arr.push(trimmed);
};
const pushInfoHash = (candidate) => {
if (!candidate) return;
const normalized = candidate.toLowerCase();
if (!/^[0-9a-f]{40}$/.test(normalized)) {
return;
}
if (infoHashCandidates.includes(normalized)) {
return;
}
infoHashCandidates.push(normalized);
};
const collectMagnetOrInfoHash = (value) => {
if (typeof value !== "string") {
return;
}
const trimmed = value.trim();
if (!trimmed) {
return;
}
if (MAGNET_URI_PATTERN.test(trimmed)) {
pushUnique(magnetCandidates, trimmed);
}
extractInfoHashesFromString(trimmed, pushInfoHash);
const urnMatch = trimmed.match(/^urn:btih:([0-9a-z]+)$/i);
if (urnMatch) {
pushInfoHash(urnMatch[1]);
}
};
if (typeof parsedContent.magnet === "string") {
pushUnique(magnetCandidates, parsedContent.magnet);
collectMagnetOrInfoHash(parsedContent.magnet);
}
if (typeof parsedContent.url === "string") {
const parsedUrl = parsedContent.url.trim();
if (parsedUrl && parsedUrl !== thumbnail) {
pushUnique(urlCandidates, parsedUrl);
}
collectMagnetOrInfoHash(parsedUrl);
}
const tags = Array.isArray(event.tags) ? event.tags : [];
@@ -57,21 +137,27 @@ export function parseVideoEventPayload(event = {}) {
if (!value) continue;
if (value.toLowerCase().startsWith("magnet:")) {
pushUnique(magnetCandidates, value);
collectMagnetOrInfoHash(value);
continue;
}
if (urlTagKeys.has(key) && /^https?:\/\//i.test(value)) {
pushUnique(urlCandidates, value);
}
collectMagnetOrInfoHash(value);
}
const magnetMatch = rawContent.match(/magnet:\?xt=urn:[^"'\s<>]+/i);
if (magnetMatch) {
pushUnique(magnetCandidates, magnetMatch[0]);
collectMagnetOrInfoHash(magnetMatch[0]);
}
extractInfoHashesFromString(rawContent, pushInfoHash);
traverseForInfoHashes(parsedContent, pushInfoHash);
const magnet = magnetCandidates.find(Boolean) || "";
const infoHash = infoHashCandidates.find(Boolean) || "";
const url =
urlCandidates.find(
(candidate) => candidate && !candidate.toLowerCase().startsWith("magnet:")
@@ -94,6 +180,7 @@ export function parseVideoEventPayload(event = {}) {
title,
url,
magnet,
infoHash,
version,
};
}

View File

@@ -0,0 +1,82 @@
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import { parseVideoEventPayload } from "../js/videoEventUtils.js";
import { convertEventToVideo } from "../js/nostr.js";
import { deriveTorrentPlaybackConfig } from "../js/playbackUtils.js";
const LEGACY_INFO_HASH = "0123456789abcdef0123456789abcdef01234567";
(function testParseDetectsBareInfoHashInJson() {
const event = {
id: "evt-json",
content: JSON.stringify({
version: 1,
title: "Legacy note",
magnet: LEGACY_INFO_HASH,
}),
tags: [],
};
const parsed = parseVideoEventPayload(event);
assert.equal(parsed.magnet, "", "Bare info hash should not be treated as a magnet URI");
assert.equal(parsed.infoHash, LEGACY_INFO_HASH);
})();
(function testParseDetectsInfoHashInRawContentAndTags() {
const event = {
id: "evt-tag",
content: JSON.stringify({ title: "tag-sourced" }),
tags: [["magnet", LEGACY_INFO_HASH]],
};
const parsed = parseVideoEventPayload(event);
assert.equal(parsed.infoHash, LEGACY_INFO_HASH);
})();
(function testParseDetectsInfoHashInRawString() {
const event = {
id: "evt-raw",
content: `legacy magnet ${LEGACY_INFO_HASH} broken json`,
tags: [],
};
const parsed = parseVideoEventPayload(event);
assert.equal(parsed.infoHash, LEGACY_INFO_HASH);
})();
(function testConvertTreatsInfoHashAsPlayable() {
const event = {
id: "evt-convert",
pubkey: "pk",
created_at: 1,
tags: [],
content: JSON.stringify({
version: 1,
title: "Legacy conversion",
magnet: LEGACY_INFO_HASH,
}),
};
const video = convertEventToVideo(event);
assert.equal(video.invalid, false, "Legacy info hash events should not be dropped");
assert.equal(video.magnet, LEGACY_INFO_HASH);
assert.equal(video.infoHash, LEGACY_INFO_HASH);
assert.equal(video.rawMagnet, "");
})();
(function testPlaybackConfigNormalizesInfoHash() {
const result = deriveTorrentPlaybackConfig({
magnet: "",
infoHash: LEGACY_INFO_HASH,
url: "",
});
assert.ok(result.magnet.startsWith("magnet:?"));
const xtValues = new URL(result.magnet).searchParams.getAll("xt");
assert.deepEqual(xtValues, [`urn:btih:${LEGACY_INFO_HASH}`]);
assert.equal(result.usedInfoHash, true);
assert.equal(result.fallbackMagnet, "");
assert.equal(result.provided, true);
})();
console.log("legacy infohash tests passed");

View File

@@ -0,0 +1,17 @@
if (typeof globalThis.localStorage === "undefined") {
const store = new Map();
globalThis.localStorage = {
getItem(key) {
return store.has(key) ? store.get(key) : null;
},
setItem(key, value) {
store.set(String(key), String(value));
},
removeItem(key) {
store.delete(key);
},
clear() {
store.clear();
},
};
}