refactor(upload): extract shared MediaUploader (step 1/3 edit-upload parity)

Extract UploadModal's storage+torrent upload core into a reusable
js/ui/components/mediaUploader.js (resolveActiveConnection, uploadThumbnail,
uploadVideo incl. infohash/.torrent/magnet) so the Edit modal can reuse it
without duplicating R2/S3 + torrent logic (and without blowing EditModal's
file-size budget). UploadModal now delegates its thumbnail/video upload core to
MediaUploader, keeping its own progress/state/zombie-guard wrappers — behavior
preserved.

UploadModal: 1319 -> 1191 lines. Build + full lint + unit suite green.

NOTE: the upload flow can't be unit-tested (browser + S3/R2 deps); this is a
behavior-preserving extraction pending manual re-test of a normal upload before
Edit upload is layered on top (steps 2-3).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-20 08:44:43 -04:00
1 parent f218ec32fa
commit 7bbf0f08b1
2 files changed
+342 -191

No files matched your search

+38 -191
View File
@@ -1,6 +1,7 @@
// components/UploadModal.js
import { createModalAccessibility } from "./modalAccessibility.js";
import { Nip71FormManager } from "./nip71FormManager.js";
import { MediaUploader } from "./mediaUploader.js";
import { devLogger, userLogger } from "../../utils/logger.js";
import {
getVideoNoteErrorMessage,
@@ -73,6 +74,15 @@ export class UploadModal {
typeof getCurrentPubkey === "function" ? getCurrentPubkey : null;
this.safeEncodeNpub =
typeof safeEncodeNpub === "function" ? safeEncodeNpub : () => "";
// Shared storage+torrent upload core (also used by the Edit modal).
this.mediaUploader = new MediaUploader({
r2Service: this.r2Service,
s3Service: this.s3Service,
storageService: this.storageService,
getCurrentPubkey: () =>
this.getCurrentPubkey ? this.getCurrentPubkey() : null,
safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey),
});
this.eventTarget =
eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
this.container = container || null;
@@ -524,159 +534,36 @@ export class UploadModal {
this.updateVideoProgress(0, "Preparing upload...");
try {
// 1. Compute Identifier (Info Hash / Fingerprint) before generating keys
const identifier = await this.resolveUploadIdentifier(file);
if (this.videoUploadId !== currentUploadId) return; // Zombie guard
// 2. Prepare Upload (Get Creds & Bucket)
const pubkey = this.getCurrentPubkey();
const npub = this.safeEncodeNpub(pubkey);
const service = this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2"
? this.r2Service
: this.s3Service;
const { settings, bucketEntry } = await service.prepareUpload(npub, { credentials: this.activeCredentials });
if (this.videoUploadId !== currentUploadId) return; // Zombie guard
// 3. Determine Keys
const videoKey = buildR2Key(npub, file, identifier);
const baseDomain = bucketEntry.publicBaseUrl;
// Note: buildPublicUrl works for both if the base is clean
// For S3, buildS3ObjectUrl might be safer if we have complex paths, but prepareUpload standardizes on publicBaseUrl
let videoPublicUrl = "";
if (this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2") {
videoPublicUrl = buildPublicUrl(baseDomain, videoKey);
} else {
videoPublicUrl = buildS3ObjectUrl({
publicBaseUrl: baseDomain,
key: videoKey,
// For S3 we might need forcePathStyle if publicBaseUrl isn't set, but prepareUpload enforces it.
});
}
// 3. Start Video Upload
this.updateVideoProgress(0, "Uploading video...");
devLogger.debug("UploadModal uploadFile settings presence", {
hasAccountId: Boolean(settings.accountId),
hasEndpoint: Boolean(settings.endpoint),
hasProvider: Boolean(settings.provider),
hasRegion: Boolean(settings.region),
hasAccessKeyId: Boolean(settings.accessKeyId),
hasSecretAccessKey: Boolean(settings.secretAccessKey),
hasForcePathStyle: settings.forcePathStyle !== undefined,
});
const uploadPromise = service.uploadFile({
file,
bucket: bucketEntry.bucket,
key: videoKey,
// Spread settings carefully
accountId: settings.accountId,
endpoint: settings.endpoint,
provider: settings.provider || this.activeProvider || "cloudflare_r2",
region: settings.region,
accessKeyId: settings.accessKeyId,
secretAccessKey: settings.secretAccessKey,
forcePathStyle: settings.forcePathStyle,
createBucketIfMissing: true,
onProgress: (fraction) => {
const result = await this.mediaUploader.uploadVideo(file, {
provider: this.activeProvider,
credentials: this.activeCredentials,
onProgress: ({ fraction, label }) => {
if (this.videoUploadId === currentUploadId) {
this.updateVideoProgress(fraction);
this.updateVideoProgress(fraction, label || undefined);
}
}
},
});
// 4. Calculate Info Hash (Parallel if possible, but JS single thread limits this.
// Since createTorrentMetadata reads the file, it might contend with upload read.
// Let's do it concurrently and hope the browser handles file I/O well.
this.updateVideoProgress(null, "Uploading & Calculating Hash...");
const torrentPromise = this.generateTorrentMetadata({ file, videoPublicUrl });
const [uploadResult, torrentResult] = await Promise.all([uploadPromise, torrentPromise]);
if (this.videoUploadId !== currentUploadId) return; // Zombie guard
// Video Complete
this.videoUploadState.status = 'complete';
this.videoUploadState.url = videoPublicUrl;
this.videoUploadState.key = videoKey;
const storagePrefix = buildStoragePrefixFromKey({
publicBaseUrl: baseDomain,
key: videoKey,
});
const providerLabel =
this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2"
? "r2"
: "s3";
this.videoUploadState.storagePointer = buildStoragePointerValue({
provider: providerLabel,
prefix: storagePrefix,
});
if (this.results.videoUrl) this.results.videoUrl.value = videoPublicUrl;
this.updateVideoProgress(1, "Video uploaded.");
// 5. Handle Torrent Result & Upload .torrent file
if (torrentResult.hasValidInfoHash && torrentResult.torrentFile) {
const baseKey = videoKey.replace(/\.[^/.]+$/, "");
const torrentKey = (baseKey && baseKey !== videoKey) ? `${baseKey}.torrent` : `${videoKey}.torrent`;
let torrentPublicUrl = "";
if (this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2") {
torrentPublicUrl = buildPublicUrl(baseDomain, torrentKey);
} else {
torrentPublicUrl = buildS3ObjectUrl({ publicBaseUrl: baseDomain, key: torrentKey });
}
this.updateVideoProgress(1, "Uploading torrent metadata...");
devLogger.debug("UploadModal uploadFile settings presence", {
hasAccountId: Boolean(settings.accountId),
hasEndpoint: Boolean(settings.endpoint),
hasProvider: Boolean(settings.provider),
hasRegion: Boolean(settings.region),
hasAccessKeyId: Boolean(settings.accessKeyId),
hasSecretAccessKey: Boolean(settings.secretAccessKey),
hasForcePathStyle: settings.forcePathStyle !== undefined,
});
await service.uploadFile({
file: torrentResult.torrentFile,
bucket: bucketEntry.bucket,
key: torrentKey,
accountId: settings.accountId,
endpoint: settings.endpoint,
provider: settings.provider || this.activeProvider || "cloudflare_r2",
region: settings.region,
accessKeyId: settings.accessKeyId,
secretAccessKey: settings.secretAccessKey,
forcePathStyle: settings.forcePathStyle,
createBucketIfMissing: true,
});
if (this.videoUploadId !== currentUploadId) return; // Zombie guard
this.videoUploadState.url = result.url;
this.videoUploadState.key = result.key;
this.videoUploadState.storagePointer = result.storagePointer;
if (this.results.videoUrl) this.results.videoUrl.value = result.url;
if (result.hasValidInfoHash) {
this.torrentState.status = 'complete';
this.torrentState.infoHash = torrentResult.infoHash;
this.torrentState.url = torrentPublicUrl;
this.torrentState.file = torrentResult.torrentFile;
this.torrentState.infoHash = result.infoHash;
this.torrentState.url = result.torrentUrl;
this.torrentState.file = result.torrentFile;
this.torrentState.magnet = result.magnet;
// Construct Magnet
const encodedDn = encodeURIComponent(file.name);
const encodedWs = encodeURIComponent(videoPublicUrl);
const encodedXs = encodeURIComponent(torrentPublicUrl);
const magnet = `magnet:?xt=urn:btih:${torrentResult.infoHash}&dn=${encodedDn}&ws=${encodedWs}&xs=${encodedXs}`;
this.torrentState.magnet = magnet;
if (this.results.magnet) this.results.magnet.value = magnet;
if (this.results.torrentUrl) this.results.torrentUrl.value = torrentPublicUrl;
this.updateVideoProgress(1, "Ready to publish!");
if (this.results.magnet) this.results.magnet.value = result.magnet;
if (this.results.torrentUrl) this.results.torrentUrl.value = result.torrentUrl;
} else {
this.torrentState.status = 'skipped'; // Failed hash or invalid
this.updateVideoProgress(1, "Upload complete (No torrent fallback).");
if (this.results.magnet) this.results.magnet.value = "Not available (Info Hash failed)";
if (this.results.torrentUrl) this.results.torrentUrl.value = "Not available";
}
@@ -725,58 +612,18 @@ export class UploadModal {
this.updateThumbnailProgress(0, "Uploading thumbnail...");
try {
const pubkey = this.getCurrentPubkey();
const npub = this.safeEncodeNpub(pubkey);
const service = this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2"
? this.r2Service
: this.s3Service;
const { settings, bucketEntry } = await service.prepareUpload(npub, { credentials: this.activeCredentials });
if (this.thumbnailUploadId !== currentUploadId) return;
// Derive Key (randomish or based on file)
// We don't have the video key here easily if video isn't selected yet.
// Use a standalone key structure: npub/thumbnails/timestamp-name
const timestamp = Date.now();
const cleanName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
const key = `${npub}/thumbnails/${timestamp}-${cleanName}`;
const baseDomain = bucketEntry.publicBaseUrl;
let publicUrl = "";
if (this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2") {
publicUrl = buildPublicUrl(baseDomain, key);
} else {
publicUrl = buildS3ObjectUrl({ publicBaseUrl: baseDomain, key });
}
devLogger.debug("UploadModal uploadFile settings presence", {
hasAccountId: Boolean(settings.accountId),
hasEndpoint: Boolean(settings.endpoint),
hasProvider: Boolean(settings.provider),
hasRegion: Boolean(settings.region),
hasAccessKeyId: Boolean(settings.accessKeyId),
hasSecretAccessKey: Boolean(settings.secretAccessKey),
hasForcePathStyle: settings.forcePathStyle !== undefined,
});
await service.uploadFile({
const { url: publicUrl } = await this.mediaUploader.uploadThumbnail(
file,
bucket: bucketEntry.bucket,
key,
accountId: settings.accountId,
endpoint: settings.endpoint,
provider: settings.provider || this.activeProvider || "cloudflare_r2",
region: settings.region,
accessKeyId: settings.accessKeyId,
secretAccessKey: settings.secretAccessKey,
forcePathStyle: settings.forcePathStyle,
createBucketIfMissing: true,
onProgress: (fraction) => {
if (this.thumbnailUploadId === currentUploadId) {
this.updateThumbnailProgress(fraction);
}
}
});
{
provider: this.activeProvider,
credentials: this.activeCredentials,
onProgress: (fraction) => {
if (this.thumbnailUploadId === currentUploadId) {
this.updateThumbnailProgress(fraction);
}
},
},
);
if (this.thumbnailUploadId !== currentUploadId) return;
+304
View File
@@ -0,0 +1,304 @@
// js/ui/components/mediaUploader.js
//
// Shared storage-upload core extracted from UploadModal so the Upload and Edit
// modals can upload thumbnails and videos without duplicating the R2/S3 +
// torrent-metadata logic. This module owns ONLY the storage/torrent work
// (resolve active connection, upload file, build keys/URLs, compute infohash,
// build the .torrent + magnet). UI concerns (progress rendering, zombie-request
// guards, form state) stay in the calling modal and are driven via onProgress.
import { devLogger, userLogger } from "../../utils/logger.js";
import {
calculateTorrentInfoHash,
createTorrentMetadata,
} from "../../utils/torrentHash.js";
import { buildR2Key, buildPublicUrl } from "../../r2.js";
import { buildS3ObjectUrl } from "../../services/s3Service.js";
import { PROVIDERS } from "../../services/storageService.js";
import {
buildStoragePointerValue,
buildStoragePrefixFromKey,
} from "../../utils/storagePointer.js";
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
function normalizeInfoHash(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function isValidInfoHash(value) {
return INFO_HASH_PATTERN.test(value);
}
function isR2Provider(provider) {
return provider === PROVIDERS.R2 || provider === "cloudflare_r2";
}
export class MediaUploader {
constructor({
r2Service,
s3Service,
storageService,
getCurrentPubkey,
safeEncodeNpub,
} = {}) {
this.r2Service = r2Service || null;
this.s3Service = s3Service || null;
this.storageService = storageService || null;
this.getCurrentPubkey =
typeof getCurrentPubkey === "function" ? getCurrentPubkey : null;
this.safeEncodeNpub =
typeof safeEncodeNpub === "function" ? safeEncodeNpub : () => "";
}
serviceFor(provider) {
return isR2Provider(provider) ? this.r2Service : this.s3Service;
}
publicUrlFor(provider, baseDomain, key) {
return isR2Provider(provider)
? buildPublicUrl(baseDomain, key)
: buildS3ObjectUrl({ publicBaseUrl: baseDomain, key });
}
/**
* Resolve the active storage connection for the current pubkey. Mirrors the
* data portion of UploadModal.loadFromStorage (no UI). Returns:
* { pubkey, npub, configured, unlocked, provider, credentials }
* credentials is only populated when the connection is unlocked.
*/
async resolveActiveConnection() {
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
const base = {
pubkey: pubkey || null,
npub: pubkey ? this.safeEncodeNpub(pubkey) : "",
configured: false,
unlocked: false,
provider: null,
credentials: null,
};
if (!pubkey || !this.storageService) {
return base;
}
base.unlocked = this.storageService.isUnlocked(pubkey);
const connections = await this.storageService.listConnections(pubkey);
const defaultConn = connections.find((c) => c.meta?.defaultForUploads);
const targetConn = defaultConn || connections[0];
if (!targetConn) {
return base;
}
base.configured = true;
base.provider = targetConn.provider;
if (base.unlocked) {
const details = await this.storageService.getConnection(
pubkey,
targetConn.id,
);
base.credentials = details || null;
}
return base;
}
/**
* Upload a standalone thumbnail file. Returns { url, key }.
*/
async uploadThumbnail(file, { provider, credentials, onProgress } = {}) {
const pubkey = this.getCurrentPubkey();
const npub = this.safeEncodeNpub(pubkey);
const service = this.serviceFor(provider);
const { settings, bucketEntry } = await service.prepareUpload(npub, {
credentials,
});
const timestamp = Date.now();
const cleanName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
const key = `${npub}/thumbnails/${timestamp}-${cleanName}`;
const baseDomain = bucketEntry.publicBaseUrl;
const url = this.publicUrlFor(provider, baseDomain, key);
await service.uploadFile({
file,
bucket: bucketEntry.bucket,
key,
accountId: settings.accountId,
endpoint: settings.endpoint,
provider: settings.provider || provider || "cloudflare_r2",
region: settings.region,
accessKeyId: settings.accessKeyId,
secretAccessKey: settings.secretAccessKey,
forcePathStyle: settings.forcePathStyle,
createBucketIfMissing: true,
onProgress: (fraction) => {
if (typeof onProgress === "function") onProgress(fraction);
},
});
return { url, key };
}
async resolveUploadIdentifier(file) {
try {
const infoHash = await calculateTorrentInfoHash(file);
const normalized = normalizeInfoHash(infoHash);
if (isValidInfoHash(normalized)) {
return normalized;
}
} catch (hashErr) {
userLogger.warn("Failed to precompute info hash for storage key:", hashErr);
}
return "";
}
async generateTorrentMetadata({ file, videoPublicUrl } = {}) {
let infoHash = "";
let torrentFile = null;
try {
const urlList = videoPublicUrl ? [videoPublicUrl] : [];
const torrentMetadata = await createTorrentMetadata(file, urlList);
infoHash = torrentMetadata?.infoHash || "";
if (torrentMetadata?.torrentFile) {
const baseName = file.name.replace(/\.[^/.]+$/, "") || file.name;
torrentFile = new File(
[torrentMetadata.torrentFile],
`${baseName}.torrent`,
{ type: "application/x-bittorrent" },
);
}
} catch (hashErr) {
userLogger.warn("Failed to calculate info hash:", hashErr);
}
const normalizedInfoHash = normalizeInfoHash(infoHash);
return {
infoHash: normalizedInfoHash,
torrentFile,
hasValidInfoHash: isValidInfoHash(normalizedInfoHash),
};
}
/**
* Upload a video file (and its generated .torrent). Returns:
* { url, key, storagePointer, infoHash, magnet, torrentUrl, torrentFile,
* hasValidInfoHash }
* onProgress is called as ({ fraction, label }) so the caller can render its
* own progress UI matching the original UploadModal labels.
*/
async uploadVideo(file, { provider, credentials, onProgress } = {}) {
const emit = (fraction, label) => {
if (typeof onProgress === "function") onProgress({ fraction, label });
};
const identifier = await this.resolveUploadIdentifier(file);
const pubkey = this.getCurrentPubkey();
const npub = this.safeEncodeNpub(pubkey);
const service = this.serviceFor(provider);
const { settings, bucketEntry } = await service.prepareUpload(npub, {
credentials,
});
const videoKey = buildR2Key(npub, file, identifier);
const baseDomain = bucketEntry.publicBaseUrl;
const videoPublicUrl = this.publicUrlFor(provider, baseDomain, videoKey);
emit(0, "Uploading video...");
const uploadPromise = service.uploadFile({
file,
bucket: bucketEntry.bucket,
key: videoKey,
accountId: settings.accountId,
endpoint: settings.endpoint,
provider: settings.provider || provider || "cloudflare_r2",
region: settings.region,
accessKeyId: settings.accessKeyId,
secretAccessKey: settings.secretAccessKey,
forcePathStyle: settings.forcePathStyle,
createBucketIfMissing: true,
onProgress: (fraction) => emit(fraction, null),
});
emit(null, "Uploading & Calculating Hash...");
const torrentPromise = this.generateTorrentMetadata({
file,
videoPublicUrl,
});
const [, torrentResult] = await Promise.all([uploadPromise, torrentPromise]);
const storagePrefix = buildStoragePrefixFromKey({
publicBaseUrl: baseDomain,
key: videoKey,
});
const providerLabel = isR2Provider(provider) ? "r2" : "s3";
const storagePointer = buildStoragePointerValue({
provider: providerLabel,
prefix: storagePrefix,
});
emit(1, "Video uploaded.");
const result = {
url: videoPublicUrl,
key: videoKey,
storagePointer,
infoHash: "",
magnet: "",
torrentUrl: "",
torrentFile: null,
hasValidInfoHash: false,
};
if (torrentResult.hasValidInfoHash && torrentResult.torrentFile) {
const baseKey = videoKey.replace(/\.[^/.]+$/, "");
const torrentKey =
baseKey && baseKey !== videoKey
? `${baseKey}.torrent`
: `${videoKey}.torrent`;
const torrentPublicUrl = this.publicUrlFor(provider, baseDomain, torrentKey);
emit(1, "Uploading torrent metadata...");
await service.uploadFile({
file: torrentResult.torrentFile,
bucket: bucketEntry.bucket,
key: torrentKey,
accountId: settings.accountId,
endpoint: settings.endpoint,
provider: settings.provider || provider || "cloudflare_r2",
region: settings.region,
accessKeyId: settings.accessKeyId,
secretAccessKey: settings.secretAccessKey,
forcePathStyle: settings.forcePathStyle,
createBucketIfMissing: true,
});
const magnet = `magnet:?xt=urn:btih:${torrentResult.infoHash}&dn=${encodeURIComponent(
file.name,
)}&ws=${encodeURIComponent(videoPublicUrl)}&xs=${encodeURIComponent(
torrentPublicUrl,
)}`;
result.infoHash = torrentResult.infoHash;
result.magnet = magnet;
result.torrentUrl = torrentPublicUrl;
result.torrentFile = torrentResult.torrentFile;
result.hasValidInfoHash = true;
emit(1, "Ready to publish!");
} else {
emit(1, "Upload complete (No torrent fallback).");
}
return result;
}
}
export { isR2Provider, normalizeInfoHash, isValidInfoHash };