mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-03-13 14:29:07 +00:00
Support legacy infohash playback
This commit is contained in:
66
js/app.js
66
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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
23
js/nostr.js
23
js/nostr.js
@@ -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
72
js/playbackUtils.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
82
tests/legacy-infohash.test.mjs
Normal file
82
tests/legacy-infohash.test.mjs
Normal 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");
|
||||
17
tests/test-helpers/setup-localstorage.mjs
Normal file
17
tests/test-helpers/setup-localstorage.mjs
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user