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(); + }, + }; +}