From 985acf645687aca8ea9f1e30f6589745b923bb52 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 23 Sep 2025 13:29:05 -0400
Subject: [PATCH] Support legacy infohash playback
---
js/app.js | 66 ++++++++++------
js/channelProfile.js | 7 +-
js/nostr.js | 23 +++++-
js/playbackUtils.js | 72 ++++++++++++++++++
js/videoEventUtils.js | 93 ++++++++++++++++++++++-
tests/legacy-infohash.test.mjs | 82 ++++++++++++++++++++
tests/test-helpers/setup-localstorage.mjs | 17 +++++
7 files changed, 327 insertions(+), 33 deletions(-)
create mode 100644 js/playbackUtils.js
create mode 100644 tests/legacy-infohash.test.mjs
create mode 100644 tests/test-helpers/setup-localstorage.mjs
diff --git a/js/app.js b/js/app.js
index c62eb13b..43c0eeee 100644
--- a/js/app.js
+++ b/js/app.js
@@ -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,
});
diff --git a/js/channelProfile.js b/js/channelProfile.js
index cb683f59..c17a65fa 100644
--- a/js/channelProfile.js
+++ b/js/channelProfile.js
@@ -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 = `
= 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,
};
}
diff --git a/tests/legacy-infohash.test.mjs b/tests/legacy-infohash.test.mjs
new file mode 100644
index 00000000..0aa76ac6
--- /dev/null
+++ b/tests/legacy-infohash.test.mjs
@@ -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");
diff --git a/tests/test-helpers/setup-localstorage.mjs b/tests/test-helpers/setup-localstorage.mjs
new file mode 100644
index 00000000..c990b187
--- /dev/null
+++ b/tests/test-helpers/setup-localstorage.mjs
@@ -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();
+ },
+ };
+}