diff --git a/js/services/r2Service.js b/js/services/r2Service.js index 5c289192..3afebcb9 100644 --- a/js/services/r2Service.js +++ b/js/services/r2Service.js @@ -551,6 +551,10 @@ class R2Service { settingsInput = null, publishVideoNote, onReset, + forcedVideoKey = "", + forcedVideoUrl = "", + forcedTorrentKey = "", + forcedTorrentUrl = "", } = {}) { if (settingsInput) { const saved = await this.handleCloudflareSettingsSubmit(settingsInput, { @@ -638,9 +642,12 @@ class R2Service { let statusMessage = `Uploading to ${bucketEntry.bucket}…`; this.setCloudflareUploadStatus(statusMessage, "info"); - const key = buildR2Key(npub, file); - const publicUrl = buildPublicUrl(bucketEntry.publicBaseUrl, key); + // Use forced keys if provided, otherwise generate them + const key = forcedVideoKey || buildR2Key(npub, file); + const publicUrl = forcedVideoUrl || buildPublicUrl(bucketEntry.publicBaseUrl, key); + const buildTorrentKey = () => { + if (forcedTorrentKey) return forcedTorrentKey; const baseKey = key.replace(/\.[^/.]+$/, ""); if (baseKey && baseKey !== key) { return `${baseKey}.torrent`; @@ -687,7 +694,7 @@ class R2Service { }); let publishOutcome = true; - let torrentUrl = ""; + let torrentUrl = forcedTorrentUrl || ""; if (torrentFile) { this.setCloudflareUploadStatus("Uploading torrent metadata...", "info"); @@ -700,7 +707,9 @@ class R2Service { file: torrentFile, contentType: "application/x-bittorrent", }); - torrentUrl = buildPublicUrl(bucketEntry.publicBaseUrl, torrentKey); + if (!torrentUrl) { + torrentUrl = buildPublicUrl(bucketEntry.publicBaseUrl, torrentKey); + } } catch (err) { userLogger.warn("Torrent metadata upload failed, continuing...", err); } @@ -722,7 +731,14 @@ class R2Service { if (hasValidInfoHash) { const encodedDn = encodeURIComponent(file.name); const encodedWs = encodeURIComponent(publicUrl); - generatedMagnet = `magnet:?xt=urn:btih:${normalizedInfoHash}&dn=${encodedDn}&ws=${encodedWs}`; + let magnet = `magnet:?xt=urn:btih:${normalizedInfoHash}&dn=${encodedDn}&ws=${encodedWs}`; + + if (torrentUrl) { + const encodedXs = encodeURIComponent(torrentUrl); + magnet += `&xs=${encodedXs}`; + } + + generatedMagnet = magnet; generatedWs = publicUrl; } else { if (infoHash) { diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 06c0d0cf..f30362bd 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -9,6 +9,7 @@ import { } from "../../services/videoNotePayload.js"; import { createTorrentMetadata } from "../../utils/torrentHash.js"; import { sanitizeBucketName } from "../../storage/r2-mgmt.js"; +import { buildR2Key, buildPublicUrl } from "../../r2.js"; const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/; @@ -660,6 +661,39 @@ export class UploadModal { const npub = this.safeEncodeNpub(pubkey); + // Pre-calculate Keys & URLs to support WebSeeding + // We need the Public URL *before* we generate the torrent so we can embed it. + let videoKey = ""; + let videoPublicUrl = ""; + let torrentKey = ""; + let torrentPublicUrl = ""; + + try { + // Temporarily grab current bucket settings to calculate URL + const bucketName = this.inputs.r2BucketName?.value || sanitizeBucketName(npub); + // Note: The actual upload might re-verify this, but we need a best-guess for the torrent. + // We rely on r2Service using the *same* logic or accepting our forced keys. + // Since we can't easily get the 'final' bucket config here without doing the 'ensureBucket' dance first, + // we'll do a lightweight check of the settings form. + const currentSettings = this.collectSettingsForm(); + if (!currentSettings.baseDomain) { + throw new Error("Missing Public URL (Base Domain) in settings."); + } + + videoKey = buildR2Key(npub, file); + // 'buildPublicUrl' logic: ${baseDomain}/${key} + videoPublicUrl = buildPublicUrl(currentSettings.baseDomain, videoKey); + + // For the torrent file, we swap extension + const baseKey = videoKey.replace(/\.[^/.]+$/, ""); + torrentKey = (baseKey && baseKey !== videoKey) ? `${baseKey}.torrent` : `${videoKey}.torrent`; + torrentPublicUrl = buildPublicUrl(currentSettings.baseDomain, torrentKey); + + } catch (prepErr) { + userLogger.warn("Failed to pre-calculate R2 keys:", prepErr); + // We continue, but WebSeed generation will fail or be skipped. + } + // 1. Calculate Torrent Info Hash (Client-side) this.isUploading = true; this.submitButton.disabled = true; @@ -669,7 +703,10 @@ export class UploadModal { let infoHash = ""; let torrentFile = null; try { - const torrentMetadata = await createTorrentMetadata(file); + // Pass the pre-calculated video URL as a WebSeed + const urlList = videoPublicUrl ? [videoPublicUrl] : []; + const torrentMetadata = await createTorrentMetadata(file, urlList); + infoHash = torrentMetadata?.infoHash || ""; if (torrentMetadata?.torrentFile) { const baseName = file.name.replace(/\.[^/.]+$/, "") || file.name; @@ -715,6 +752,11 @@ export class UploadModal { settingsInput: this.collectSettingsForm(), publishVideoNote: this.publishVideoNote, onReset: () => this.resetForm(), + // Pass forced keys/URLs to ensure what's in the torrent matches where we upload + forcedVideoKey: videoKey, + forcedVideoUrl: videoPublicUrl, + forcedTorrentKey: torrentKey, + forcedTorrentUrl: torrentPublicUrl, }); } diff --git a/js/utils/torrentHash.js b/js/utils/torrentHash.js index 02c1cd60..9f5144ca 100644 --- a/js/utils/torrentHash.js +++ b/js/utils/torrentHash.js @@ -6,7 +6,7 @@ const TORRENT_SEED_OPTIONS = { destroyStoreOnDestroy: true, }; -function seedTorrentForFile(file) { +function seedTorrentForFile(file, urlList = []) { if (!file) { return Promise.reject(new Error("No file provided for hashing.")); } @@ -25,6 +25,7 @@ function seedTorrentForFile(file) { { ...TORRENT_SEED_OPTIONS, name: file.name, + urlList: urlList, }, (torrent) => { const infoHash = torrent.infoHash; @@ -66,6 +67,6 @@ export async function calculateTorrentInfoHash(file) { return infoHash; } -export async function createTorrentMetadata(file) { - return seedTorrentForFile(file); +export async function createTorrentMetadata(file, urlList = []) { + return seedTorrentForFile(file, urlList); }