mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-05 12:22:28 +00:00
Merge pull request #1691 from PR0M3TH3AN/video-upload-refactor-10784132771070758268
Refactor Video Upload to Immediate Flow & Fix Logger Crash
This commit is contained in:
4 files changed
+514
-265
No files matched your search
@@ -103,6 +103,27 @@
|
||||
></progress>
|
||||
</div>
|
||||
|
||||
<!-- Generated Links (Results) -->
|
||||
<div id="upload-results-container" class="space-y-3 bg-surface/30 p-3 rounded-lg border border-border/50 hidden">
|
||||
<h4 class="text-xs font-semibold text-text uppercase tracking-wider">Generated Links</h4>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs text-muted">Video URL</label>
|
||||
<input id="result-video-url" type="text" readonly class="input input-sm text-xs bg-surface-alt font-mono text-muted" placeholder="Pending..." />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs text-muted">Magnet URI</label>
|
||||
<input id="result-magnet" type="text" readonly class="input input-sm text-xs bg-surface-alt font-mono text-muted" placeholder="Pending..." />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs text-muted">Torrent URL (xs)</label>
|
||||
<input id="result-torrent-url" type="text" readonly class="input input-sm text-xs bg-surface-alt font-mono text-muted" placeholder="Pending..." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Configuration Toggle -->
|
||||
<div class="border-t border-border/50 pt-2">
|
||||
<button
|
||||
@@ -209,6 +230,19 @@
|
||||
Browse...
|
||||
</button>
|
||||
</div>
|
||||
<!-- Thumbnail Progress -->
|
||||
<div id="thumbnail-progress-container" class="space-y-1 hidden mt-2">
|
||||
<div class="flex justify-between text-xs">
|
||||
<span id="thumbnail-status-text" class="text-text font-medium">Uploading Thumbnail...</span>
|
||||
<span id="thumbnail-percent-text" class="text-muted">0%</span>
|
||||
</div>
|
||||
<progress
|
||||
id="thumbnail-progress"
|
||||
class="progress progress--surface w-full h-1.5"
|
||||
max="100"
|
||||
value="0"
|
||||
></progress>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hashtags -->
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
} from "../storage/s3-multipart.js";
|
||||
import { ensureS3SdkLoaded, makeS3Client } from "../storage/s3-client.js";
|
||||
import { truncateMiddle } from "../utils/formatters.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
import { userLogger, devLogger } from "../utils/logger.js";
|
||||
import {
|
||||
getVideoNoteErrorMessage,
|
||||
normalizeVideoNotePayload,
|
||||
@@ -555,7 +555,7 @@ class R2Service {
|
||||
} catch (createErr) {
|
||||
// 403 Forbidden is expected if keys are "Object Read & Write" only.
|
||||
// We proceed assuming the user might have created it manually.
|
||||
userLogger.debug(
|
||||
devLogger.debug(
|
||||
"Auto-creation of bucket failed (likely permission issue), proceeding...",
|
||||
createErr
|
||||
);
|
||||
@@ -749,6 +749,50 @@ class R2Service {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares for an upload by resolving credentials and ensuring the bucket exists.
|
||||
* @param {string} npub
|
||||
* @param {object} options
|
||||
* @returns {Promise<{settings: object, bucketEntry: object}>}
|
||||
*/
|
||||
async prepareUpload(npub, { credentials } = {}) {
|
||||
// Resolve settings from StorageService if explicitCredentials missing
|
||||
let effectiveSettings = credentials;
|
||||
if (!effectiveSettings) {
|
||||
effectiveSettings = await this.resolveConnection(npub);
|
||||
}
|
||||
if (!effectiveSettings) {
|
||||
effectiveSettings = this.cloudflareSettings || {};
|
||||
}
|
||||
|
||||
const accountId = (effectiveSettings.accountId || "").trim();
|
||||
const accessKeyId = (effectiveSettings.accessKeyId || "").trim();
|
||||
const secretAccessKey = (effectiveSettings.secretAccessKey || "").trim();
|
||||
|
||||
if (!accountId || !accessKeyId || !secretAccessKey) {
|
||||
throw new Error("Missing R2 credentials. Unlock your storage or save settings.");
|
||||
}
|
||||
|
||||
let bucketResult = null;
|
||||
try {
|
||||
bucketResult = await this.ensureBucketConfigForNpub(npub, {
|
||||
credentials: effectiveSettings,
|
||||
});
|
||||
} catch (err) {
|
||||
userLogger.error("Failed to prepare R2 bucket:", err);
|
||||
throw new Error(err?.message ? `Bucket setup failed: ${err.message}` : "Bucket setup failed.");
|
||||
}
|
||||
|
||||
const bucketEntry =
|
||||
bucketResult?.entry || this.cloudflareSettings?.buckets?.[npub];
|
||||
|
||||
if (!bucketEntry || !bucketEntry.publicBaseUrl) {
|
||||
throw new Error("Bucket is missing a public URL. Check your settings.");
|
||||
}
|
||||
|
||||
return { settings: effectiveSettings, bucketEntry };
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the entire video upload workflow.
|
||||
*
|
||||
|
||||
@@ -98,6 +98,68 @@ class S3UploadService {
|
||||
return prepared;
|
||||
}
|
||||
|
||||
async prepareUpload(settings = {}, { createBucketIfMissing = true } = {}) {
|
||||
const prepared = await this.verifyConnection({ settings, createBucketIfMissing });
|
||||
return {
|
||||
settings: prepared,
|
||||
bucketEntry: {
|
||||
bucket: prepared.bucket,
|
||||
publicBaseUrl: prepared.publicBaseUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async uploadFile({
|
||||
file,
|
||||
endpoint,
|
||||
region,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
forcePathStyle,
|
||||
bucket,
|
||||
key,
|
||||
onProgress,
|
||||
createBucketIfMissing = true,
|
||||
} = {}) {
|
||||
if (
|
||||
!file ||
|
||||
!bucket ||
|
||||
!key ||
|
||||
!endpoint ||
|
||||
!accessKeyId ||
|
||||
!secretAccessKey
|
||||
) {
|
||||
throw new Error("Missing required parameters for uploadFile");
|
||||
}
|
||||
|
||||
await ensureS3SdkLoaded();
|
||||
|
||||
const s3 = makeS3Client({
|
||||
endpoint,
|
||||
region,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
forcePathStyle: Boolean(forcePathStyle),
|
||||
});
|
||||
|
||||
await multipartUpload({
|
||||
s3,
|
||||
bucket,
|
||||
key,
|
||||
file,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
createBucketIfMissing,
|
||||
region,
|
||||
onProgress: (fraction) => {
|
||||
if (typeof onProgress === "function") {
|
||||
onProgress(fraction);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return { bucket, key };
|
||||
}
|
||||
|
||||
async uploadVideo({
|
||||
npub = "",
|
||||
file = null,
|
||||
|
||||
+372
-263
@@ -79,11 +79,35 @@ export class UploadModal {
|
||||
this.isStorageUnlocked = false;
|
||||
this.storageConfigured = false;
|
||||
|
||||
// Upload State
|
||||
this.videoUploadState = {
|
||||
status: 'idle', // idle, uploading, complete, error
|
||||
progress: 0,
|
||||
url: '',
|
||||
key: '',
|
||||
file: null,
|
||||
};
|
||||
this.thumbnailUploadState = {
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
url: '',
|
||||
key: '',
|
||||
file: null,
|
||||
};
|
||||
this.torrentState = {
|
||||
status: 'idle',
|
||||
infoHash: '',
|
||||
magnet: '',
|
||||
url: '', // xs (torrent file url)
|
||||
file: null,
|
||||
};
|
||||
|
||||
// UI References
|
||||
this.form = null;
|
||||
this.modeButtons = {};
|
||||
this.sourceSections = {};
|
||||
this.inputs = {};
|
||||
this.results = {}; // New results section
|
||||
this.toggles = {};
|
||||
this.submitButton = null;
|
||||
this.submitStatus = null;
|
||||
@@ -98,7 +122,7 @@ export class UploadModal {
|
||||
|
||||
// Automation
|
||||
this.summaryLocked = true;
|
||||
this.isUploading = false;
|
||||
this.isUploading = false; // Global lock
|
||||
}
|
||||
|
||||
// --- Core Lifecycle ---
|
||||
@@ -131,7 +155,7 @@ export class UploadModal {
|
||||
this.cacheElements();
|
||||
this.bindEvents();
|
||||
this.setupModalAccessibility();
|
||||
this.registerStorageSubscriptions();
|
||||
// this.registerStorageSubscriptions(); // We handle progress locally per file now
|
||||
|
||||
// Initial State
|
||||
if (this.storageService) {
|
||||
@@ -173,6 +197,8 @@ export class UploadModal {
|
||||
settings: $("#section-storage-settings"),
|
||||
advanced: $("#section-advanced"),
|
||||
progress: $("#upload-progress-container"),
|
||||
thumbnailProgress: $("#thumbnail-progress-container"),
|
||||
results: $("#upload-results-container"),
|
||||
};
|
||||
|
||||
this.storageViews = {
|
||||
@@ -199,6 +225,14 @@ export class UploadModal {
|
||||
|
||||
// Progress
|
||||
progress: $("#input-progress"),
|
||||
thumbnailProgress: $("#thumbnail-progress"),
|
||||
};
|
||||
|
||||
// Results (Generated Links)
|
||||
this.results = {
|
||||
videoUrl: $("#result-video-url"),
|
||||
magnet: $("#result-magnet"),
|
||||
torrentUrl: $("#result-torrent-url"),
|
||||
};
|
||||
|
||||
// Toggles/Buttons
|
||||
@@ -221,6 +255,8 @@ export class UploadModal {
|
||||
storageLock: $("#storage-lock-status"),
|
||||
uploadMain: $("#upload-status-text"),
|
||||
uploadPercent: $("#upload-percent-text"),
|
||||
thumbnailMain: $("#thumbnail-status-text"),
|
||||
thumbnailPercent: $("#thumbnail-percent-text"),
|
||||
summaryProvider: $("#summary-provider"),
|
||||
summaryBucket: $("#summary-bucket"),
|
||||
summaryUrlStyle: $("#summary-url-style"),
|
||||
@@ -250,7 +286,7 @@ export class UploadModal {
|
||||
this.toggles.storageUnlock.addEventListener("click", () => this.handleUnlock());
|
||||
}
|
||||
|
||||
// Manage Storage (Summary View)
|
||||
// Manage Storage
|
||||
if (this.toggles.manageStorage) {
|
||||
this.toggles.manageStorage.addEventListener("click", () => {
|
||||
if (this.onRequestStorageSettings) {
|
||||
@@ -260,10 +296,9 @@ export class UploadModal {
|
||||
});
|
||||
}
|
||||
|
||||
// Configure Storage (Empty View)
|
||||
if (this.toggles.configureStorage) {
|
||||
this.toggles.configureStorage.addEventListener("click", (e) => {
|
||||
e.preventDefault(); // Prevent form submission
|
||||
e.preventDefault();
|
||||
if (this.onRequestStorageSettings) {
|
||||
this.close();
|
||||
this.onRequestStorageSettings();
|
||||
@@ -277,6 +312,11 @@ export class UploadModal {
|
||||
this.setupNsfwToContentWarning();
|
||||
this.setupThumbnailInput();
|
||||
|
||||
// File Selection (Triggers upload immediately)
|
||||
if (this.inputs.file) {
|
||||
this.inputs.file.addEventListener("change", (e) => this.handleVideoSelection(e));
|
||||
}
|
||||
|
||||
// Close
|
||||
this.closeButton.addEventListener("click", () => this.close());
|
||||
|
||||
@@ -292,7 +332,6 @@ export class UploadModal {
|
||||
// UI Updates
|
||||
const isUpload = mode === "upload";
|
||||
|
||||
// Toggle Buttons
|
||||
this.modeButtons.upload.setAttribute("aria-pressed", isUpload);
|
||||
this.modeButtons.upload.classList.toggle("bg-surface", isUpload);
|
||||
this.modeButtons.upload.classList.toggle("text-text", isUpload);
|
||||
@@ -309,6 +348,10 @@ export class UploadModal {
|
||||
if (isUpload) {
|
||||
this.sourceSections.upload.classList.remove("hidden");
|
||||
this.sourceSections.external.classList.add("hidden");
|
||||
// Show results if we have them
|
||||
if (this.videoUploadState.url || this.torrentState.magnet) {
|
||||
this.sourceSections.results.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (this.toggles.browseThumbnail) {
|
||||
this.toggles.browseThumbnail.classList.remove("hidden");
|
||||
@@ -316,14 +359,14 @@ export class UploadModal {
|
||||
} else {
|
||||
this.sourceSections.upload.classList.add("hidden");
|
||||
this.sourceSections.external.classList.remove("hidden");
|
||||
this.sourceSections.results.classList.add("hidden");
|
||||
|
||||
if (this.toggles.browseThumbnail) {
|
||||
this.toggles.browseThumbnail.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// Update Button Text
|
||||
this.submitButton.textContent = isUpload ? "Upload & Publish" : "Publish Video";
|
||||
this.submitButton.textContent = isUpload ? "Publish Video" : "Publish Video";
|
||||
}
|
||||
|
||||
setupAccordion(btn, section) {
|
||||
@@ -333,7 +376,6 @@ export class UploadModal {
|
||||
if (isHidden) {
|
||||
section.classList.remove("hidden");
|
||||
btn.setAttribute("aria-expanded", "true");
|
||||
// Rotate icon if exists
|
||||
const icon = btn.querySelector("svg");
|
||||
if (icon) icon.classList.add("rotate-90");
|
||||
} else {
|
||||
@@ -402,9 +444,10 @@ export class UploadModal {
|
||||
thumbnailFile.addEventListener("change", () => {
|
||||
const file = thumbnailFile.files?.[0];
|
||||
if (file) {
|
||||
thumbnail.value = ""; // Clear explicit URL
|
||||
thumbnail.value = "";
|
||||
thumbnail.placeholder = `Selected: ${file.name}`;
|
||||
thumbnail.disabled = true;
|
||||
this.handleThumbnailSelection(file); // Trigger upload immediately
|
||||
} else {
|
||||
thumbnail.placeholder = "https://example.com/thumbnail.jpg";
|
||||
thumbnail.disabled = false;
|
||||
@@ -412,7 +455,272 @@ export class UploadModal {
|
||||
});
|
||||
}
|
||||
|
||||
// --- R2/Storage Integration ---
|
||||
// --- Immediate Upload Handlers ---
|
||||
|
||||
async handleVideoSelection(e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!this.storageConfigured) {
|
||||
alert("Please configure storage before selecting a file.");
|
||||
e.target.value = ""; // Clear selection
|
||||
return;
|
||||
}
|
||||
if (!this.isStorageUnlocked) {
|
||||
alert("Please unlock storage before selecting a file.");
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset Video State
|
||||
this.videoUploadState = {
|
||||
status: 'uploading',
|
||||
progress: 0,
|
||||
url: '',
|
||||
key: '',
|
||||
file: file,
|
||||
};
|
||||
// Reset Torrent State
|
||||
this.torrentState = {
|
||||
status: 'pending',
|
||||
infoHash: '',
|
||||
magnet: '',
|
||||
url: '',
|
||||
file: null,
|
||||
};
|
||||
|
||||
// Show UI
|
||||
this.sourceSections.progress.classList.remove("hidden");
|
||||
this.sourceSections.results.classList.remove("hidden");
|
||||
this.results.videoUrl.value = "Uploading...";
|
||||
this.results.magnet.value = "Pending...";
|
||||
this.results.torrentUrl.value = "Pending...";
|
||||
|
||||
this.updateVideoProgress(0, "Preparing upload...");
|
||||
|
||||
try {
|
||||
// 1. 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 });
|
||||
|
||||
// 2. Determine Keys
|
||||
const videoKey = buildR2Key(npub, file);
|
||||
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...");
|
||||
const uploadPromise = service.uploadFile({
|
||||
file,
|
||||
bucket: bucketEntry.bucket,
|
||||
key: videoKey,
|
||||
// Spread settings carefully
|
||||
endpoint: settings.endpoint,
|
||||
region: settings.region,
|
||||
accessKeyId: settings.accessKeyId,
|
||||
secretAccessKey: settings.secretAccessKey,
|
||||
forcePathStyle: settings.forcePathStyle,
|
||||
createBucketIfMissing: true,
|
||||
onProgress: (fraction) => {
|
||||
this.updateVideoProgress(fraction);
|
||||
}
|
||||
});
|
||||
|
||||
// 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]);
|
||||
|
||||
// Video Complete
|
||||
this.videoUploadState.status = 'complete';
|
||||
this.videoUploadState.url = videoPublicUrl;
|
||||
this.videoUploadState.key = videoKey;
|
||||
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...");
|
||||
|
||||
await service.uploadFile({
|
||||
file: torrentResult.torrentFile,
|
||||
bucket: bucketEntry.bucket,
|
||||
key: torrentKey,
|
||||
endpoint: settings.endpoint,
|
||||
region: settings.region,
|
||||
accessKeyId: settings.accessKeyId,
|
||||
secretAccessKey: settings.secretAccessKey,
|
||||
forcePathStyle: settings.forcePathStyle,
|
||||
createBucketIfMissing: true,
|
||||
});
|
||||
|
||||
this.torrentState.status = 'complete';
|
||||
this.torrentState.infoHash = torrentResult.infoHash;
|
||||
this.torrentState.url = torrentPublicUrl;
|
||||
this.torrentState.file = torrentResult.torrentFile;
|
||||
|
||||
// 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;
|
||||
|
||||
this.results.magnet.value = magnet;
|
||||
this.results.torrentUrl.value = torrentPublicUrl;
|
||||
|
||||
this.updateVideoProgress(1, "Ready to publish!");
|
||||
} else {
|
||||
this.torrentState.status = 'skipped'; // Failed hash or invalid
|
||||
this.updateVideoProgress(1, "Upload complete (No torrent fallback).");
|
||||
this.results.magnet.value = "Not available (Info Hash failed)";
|
||||
this.results.torrentUrl.value = "Not available";
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
userLogger.error("Video upload sequence failed:", err);
|
||||
this.videoUploadState.status = 'error';
|
||||
this.updateVideoProgress(null, "Upload failed.");
|
||||
alert(`Upload failed: ${err.message}`);
|
||||
|
||||
this.sourceSections.progress.classList.add("hidden");
|
||||
this.inputs.file.value = ""; // Reset
|
||||
}
|
||||
}
|
||||
|
||||
async handleThumbnailSelection(file) {
|
||||
if (!this.storageConfigured || !this.isStorageUnlocked) {
|
||||
// We can't upload yet. Just hold it in state or warn?
|
||||
// Since UI disables the browse button until unlocked (mostly), we assume safe.
|
||||
// But if they just unlocked, we're good.
|
||||
return;
|
||||
}
|
||||
|
||||
this.thumbnailUploadState = {
|
||||
status: 'uploading',
|
||||
progress: 0,
|
||||
url: '',
|
||||
key: '',
|
||||
file: file,
|
||||
};
|
||||
|
||||
this.sourceSections.thumbnailProgress.classList.remove("hidden");
|
||||
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 });
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
await service.uploadFile({
|
||||
file,
|
||||
bucket: bucketEntry.bucket,
|
||||
key,
|
||||
endpoint: settings.endpoint,
|
||||
region: settings.region,
|
||||
accessKeyId: settings.accessKeyId,
|
||||
secretAccessKey: settings.secretAccessKey,
|
||||
forcePathStyle: settings.forcePathStyle,
|
||||
createBucketIfMissing: true,
|
||||
onProgress: (fraction) => this.updateThumbnailProgress(fraction)
|
||||
});
|
||||
|
||||
this.thumbnailUploadState.status = 'complete';
|
||||
this.thumbnailUploadState.url = publicUrl;
|
||||
this.inputs.thumbnail.value = publicUrl;
|
||||
|
||||
this.updateThumbnailProgress(1, "Thumbnail uploaded.");
|
||||
|
||||
// Hide progress after a delay
|
||||
setTimeout(() => {
|
||||
this.sourceSections.thumbnailProgress.classList.add("hidden");
|
||||
}, 2000);
|
||||
|
||||
} catch (err) {
|
||||
userLogger.error("Thumbnail upload failed:", err);
|
||||
this.thumbnailUploadState.status = 'error';
|
||||
this.updateThumbnailProgress(null, "Failed.");
|
||||
alert("Thumbnail upload failed.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
updateVideoProgress(fraction, text) {
|
||||
if (text) this.statusText.uploadMain.textContent = text;
|
||||
|
||||
if (fraction === null) {
|
||||
// Indeterminate or error
|
||||
return;
|
||||
}
|
||||
|
||||
const pct = Math.round(fraction * 100);
|
||||
this.inputs.progress.value = pct;
|
||||
this.statusText.uploadPercent.textContent = `${pct}%`;
|
||||
}
|
||||
|
||||
updateThumbnailProgress(fraction, text) {
|
||||
if (text) this.statusText.thumbnailMain.textContent = text;
|
||||
|
||||
if (fraction === null) return;
|
||||
|
||||
const pct = Math.round(fraction * 100);
|
||||
this.inputs.thumbnailProgress.value = pct;
|
||||
this.statusText.thumbnailPercent.textContent = `${pct}%`;
|
||||
}
|
||||
|
||||
|
||||
// --- R2/Storage Integration (View Only now) ---
|
||||
|
||||
async refreshState() {
|
||||
if (this.storageService) {
|
||||
@@ -581,56 +889,27 @@ export class UploadModal {
|
||||
return "Uploads will target your S3-compatible bucket.";
|
||||
}
|
||||
|
||||
registerStorageSubscriptions() {
|
||||
const services = [this.r2Service, this.s3Service].filter(
|
||||
(service) => service && typeof service.on === "function"
|
||||
);
|
||||
|
||||
// Clear old
|
||||
this.r2Unsubscribes.forEach(u => u && u());
|
||||
this.r2Unsubscribes = [];
|
||||
|
||||
services.forEach((service) => {
|
||||
const sub = (evt, fn) => {
|
||||
const unsub = service.on(evt, fn);
|
||||
if (unsub) this.r2Unsubscribes.push(unsub);
|
||||
};
|
||||
|
||||
sub("uploadProgress", ({ fraction }) => this.updateProgress(fraction));
|
||||
sub("uploadStatus", ({ message, variant }) => this.updateUploadStatus(message, variant));
|
||||
sub("uploadStateChange", ({ isUploading }) => {
|
||||
this.isUploading = isUploading;
|
||||
this.submitButton.disabled = isUploading;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateProgress(fraction) {
|
||||
const container = this.sourceSections.progress;
|
||||
const bar = this.inputs.progress;
|
||||
const txt = this.statusText.uploadPercent;
|
||||
|
||||
if (fraction === null || fraction < 0 || isNaN(fraction)) {
|
||||
container.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
container.classList.remove("hidden");
|
||||
const pct = Math.round(fraction * 100);
|
||||
bar.value = pct;
|
||||
txt.textContent = `${pct}%`;
|
||||
}
|
||||
|
||||
updateUploadStatus(msg, variant) {
|
||||
if (this.statusText.uploadMain) {
|
||||
this.statusText.uploadMain.textContent = msg;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Submission ---
|
||||
|
||||
async handleSubmit() {
|
||||
if (this.isUploading) return;
|
||||
// Check upload state
|
||||
if (this.activeSource === "upload") {
|
||||
if (this.videoUploadState.status === 'uploading' || this.thumbnailUploadState.status === 'uploading') {
|
||||
alert("Please wait for uploads to complete.");
|
||||
return;
|
||||
}
|
||||
if (this.videoUploadState.status === 'error') {
|
||||
alert("Video upload failed. Please try again.");
|
||||
return;
|
||||
}
|
||||
if (this.videoUploadState.status !== 'complete') {
|
||||
// Fallback: If no file selected, maybe they want to submit without a new file?
|
||||
// (Not supported in this simplified modal, assume file required)
|
||||
alert("Please select a video file and wait for it to upload.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const audienceFlags = {
|
||||
isNsfw: this.toggles.nsfw?.checked || false,
|
||||
@@ -660,219 +939,38 @@ export class UploadModal {
|
||||
|
||||
try {
|
||||
if (this.activeSource === "upload") {
|
||||
await this.handleUploadFlow(metadata);
|
||||
// We already have the URLs in state
|
||||
metadata.url = this.videoUploadState.url;
|
||||
metadata.magnet = this.torrentState.magnet || "";
|
||||
metadata.ws = this.videoUploadState.url; // WebSeed is same as R2 URL
|
||||
metadata.xs = this.torrentState.url || "";
|
||||
|
||||
// NIP-71 IMETA for the main video
|
||||
// We construct a primary imeta entry for the uploaded file
|
||||
const imeta = {
|
||||
url: this.videoUploadState.url,
|
||||
m: this.videoUploadState.file?.type || "video/mp4",
|
||||
x: this.torrentState.infoHash || "",
|
||||
};
|
||||
|
||||
// Merge with user provided imeta if any
|
||||
if (!metadata.nip71) metadata.nip71 = {};
|
||||
if (!metadata.nip71.imeta) metadata.nip71.imeta = [];
|
||||
metadata.nip71.imeta.unshift(imeta);
|
||||
|
||||
await this.publish(metadata);
|
||||
|
||||
} else {
|
||||
await this.handleExternalFlow(metadata);
|
||||
}
|
||||
} catch (err) {
|
||||
userLogger.error("Upload failed", err);
|
||||
userLogger.error("Submission failed", err);
|
||||
this.showError(err.message || "An unexpected error occurred.");
|
||||
}
|
||||
}
|
||||
|
||||
async handleUploadFlow(metadata) {
|
||||
const file = this.inputs.file?.files?.[0];
|
||||
if (!file) throw new Error("Please select a video file to upload.");
|
||||
|
||||
if (!this.storageConfigured) throw new Error("Please configure storage first.");
|
||||
if (!this.activeCredentials) throw new Error("Storage is locked. Please unlock it to upload.");
|
||||
|
||||
// Check if we have R2 or Generic S3 credentials
|
||||
const isR2 = this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2";
|
||||
|
||||
return isR2
|
||||
? this.handleR2UploadFlow(metadata, file)
|
||||
: this.handleS3UploadFlow(metadata, file);
|
||||
}
|
||||
|
||||
async handleR2UploadFlow(metadata, file) {
|
||||
const thumbnailFile = this.inputs.thumbnailFile?.files?.[0];
|
||||
const settings = this.activeCredentials;
|
||||
|
||||
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
|
||||
if (!pubkey) throw new Error("Please login to publish.");
|
||||
|
||||
const npub = this.safeEncodeNpub(pubkey);
|
||||
|
||||
let videoKey = "";
|
||||
let videoPublicUrl = "";
|
||||
let torrentKey = "";
|
||||
let torrentPublicUrl = "";
|
||||
|
||||
// Validate baseDomain
|
||||
const baseDomain = settings.baseDomain || settings.meta?.baseDomain || settings.meta?.prefix;
|
||||
if (!baseDomain) {
|
||||
throw new Error("Missing Public URL (Base Domain) in settings.");
|
||||
}
|
||||
|
||||
try {
|
||||
videoKey = buildR2Key(npub, file);
|
||||
videoPublicUrl = buildPublicUrl(baseDomain, videoKey);
|
||||
|
||||
const baseKey = videoKey.replace(/\.[^/.]+$/, "");
|
||||
torrentKey = (baseKey && baseKey !== videoKey) ? `${baseKey}.torrent` : `${videoKey}.torrent`;
|
||||
torrentPublicUrl = buildPublicUrl(baseDomain, torrentKey);
|
||||
} catch (prepErr) {
|
||||
userLogger.warn("Failed to pre-calculate R2 keys:", prepErr);
|
||||
}
|
||||
|
||||
const { infoHash, torrentFile, hasValidInfoHash } = await this.generateTorrentMetadata({
|
||||
file,
|
||||
videoPublicUrl,
|
||||
});
|
||||
|
||||
if (!hasValidInfoHash) {
|
||||
const proceed = confirm(
|
||||
"We couldn't calculate a valid info hash. Publishing will continue with URL-first playback only, and WebTorrent fallback will be unavailable. Continue?"
|
||||
);
|
||||
if (!proceed) {
|
||||
this.updateUploadStatus(
|
||||
"Upload canceled. A valid info hash is required for WebTorrent fallback.",
|
||||
"warning"
|
||||
);
|
||||
this.isUploading = false;
|
||||
this.submitButton.disabled = false;
|
||||
this.updateProgress(null);
|
||||
return;
|
||||
}
|
||||
this.updateUploadStatus(
|
||||
"Continuing without WebTorrent fallback (info hash unavailable).",
|
||||
"warning"
|
||||
);
|
||||
}
|
||||
|
||||
// Reconstruct simple settings object expected by r2Service if needed
|
||||
// r2Service expects: { accountId, accessKeyId, secretAccessKey, baseDomain }
|
||||
// Our stored credential object has flat properties for keys.
|
||||
const uploadSettings = {
|
||||
accountId: settings.accountId,
|
||||
accessKeyId: settings.accessKeyId,
|
||||
secretAccessKey: settings.secretAccessKey,
|
||||
baseDomain: baseDomain,
|
||||
};
|
||||
|
||||
await this.r2Service.uploadVideo({
|
||||
npub,
|
||||
file,
|
||||
thumbnailFile,
|
||||
torrentFile,
|
||||
metadata,
|
||||
infoHash: hasValidInfoHash ? infoHash : "",
|
||||
settingsInput: null, // No manual input
|
||||
explicitCredentials: uploadSettings,
|
||||
publishVideoNote: this.publishVideoNote,
|
||||
onReset: () => this.resetForm(),
|
||||
forcedVideoKey: videoKey,
|
||||
forcedVideoUrl: videoPublicUrl,
|
||||
forcedTorrentKey: torrentKey,
|
||||
forcedTorrentUrl: torrentPublicUrl,
|
||||
});
|
||||
}
|
||||
|
||||
async handleS3UploadFlow(metadata, file) {
|
||||
const thumbnailFile = this.inputs.thumbnailFile?.files?.[0];
|
||||
const settings = this.activeCredentials;
|
||||
|
||||
if (!this.s3Service) throw new Error("S3 upload service is unavailable.");
|
||||
|
||||
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
|
||||
if (!pubkey) throw new Error("Please login to publish.");
|
||||
|
||||
const npub = this.safeEncodeNpub(pubkey);
|
||||
|
||||
// Reconstruct settings object for s3Service
|
||||
// s3Service expects: { endpoint, region, bucket, accessKeyId, secretAccessKey, publicBaseUrl, forcePathStyle }
|
||||
const uploadSettings = {
|
||||
endpoint: settings.endpoint || settings.meta?.endpoint,
|
||||
region: settings.region || settings.meta?.region || "auto",
|
||||
bucket: settings.bucket || settings.meta?.bucket,
|
||||
accessKeyId: settings.accessKeyId,
|
||||
secretAccessKey: settings.secretAccessKey,
|
||||
publicBaseUrl: settings.meta?.publicBaseUrl,
|
||||
forcePathStyle: settings.forcePathStyle, // might be boolean in root or meta?
|
||||
};
|
||||
|
||||
// Ensure forcePathStyle is boolean
|
||||
if (typeof uploadSettings.forcePathStyle !== 'boolean') {
|
||||
uploadSettings.forcePathStyle = !!settings.meta?.forcePathStyle;
|
||||
}
|
||||
|
||||
let videoKey = "";
|
||||
let videoPublicUrl = "";
|
||||
let torrentKey = "";
|
||||
let torrentPublicUrl = "";
|
||||
|
||||
try {
|
||||
videoKey = buildR2Key(npub, file);
|
||||
videoPublicUrl = buildS3ObjectUrl({
|
||||
publicBaseUrl: uploadSettings.publicBaseUrl,
|
||||
endpoint: uploadSettings.endpoint,
|
||||
bucket: uploadSettings.bucket,
|
||||
key: videoKey,
|
||||
forcePathStyle: uploadSettings.forcePathStyle,
|
||||
});
|
||||
const baseKey = videoKey.replace(/\.[^/.]+$/, "");
|
||||
torrentKey = (baseKey && baseKey !== videoKey) ? `${baseKey}.torrent` : `${videoKey}.torrent`;
|
||||
torrentPublicUrl = buildS3ObjectUrl({
|
||||
publicBaseUrl: uploadSettings.publicBaseUrl,
|
||||
endpoint: uploadSettings.endpoint,
|
||||
bucket: uploadSettings.bucket,
|
||||
key: torrentKey,
|
||||
forcePathStyle: uploadSettings.forcePathStyle,
|
||||
});
|
||||
} catch (prepErr) {
|
||||
userLogger.warn("Failed to pre-calculate S3 keys:", prepErr);
|
||||
}
|
||||
|
||||
const { infoHash, torrentFile, hasValidInfoHash } = await this.generateTorrentMetadata({
|
||||
file,
|
||||
videoPublicUrl,
|
||||
});
|
||||
|
||||
if (!hasValidInfoHash) {
|
||||
const proceed = confirm(
|
||||
"We couldn't calculate a valid info hash. Publishing will continue with URL-first playback only, and WebTorrent fallback will be unavailable. Continue?"
|
||||
);
|
||||
if (!proceed) {
|
||||
this.updateUploadStatus(
|
||||
"Upload canceled. A valid info hash is required for WebTorrent fallback.",
|
||||
"warning"
|
||||
);
|
||||
this.isUploading = false;
|
||||
this.submitButton.disabled = false;
|
||||
this.updateProgress(null);
|
||||
return;
|
||||
}
|
||||
this.updateUploadStatus(
|
||||
"Continuing without WebTorrent fallback (info hash unavailable).",
|
||||
"warning"
|
||||
);
|
||||
}
|
||||
|
||||
await this.s3Service.uploadVideo({
|
||||
npub,
|
||||
file,
|
||||
thumbnailFile,
|
||||
torrentFile,
|
||||
metadata,
|
||||
infoHash: hasValidInfoHash ? infoHash : "",
|
||||
settings: uploadSettings,
|
||||
createBucketIfMissing: !!settings.meta?.createBucketIfMissing, // Use meta flag if present
|
||||
publishVideoNote: this.publishVideoNote,
|
||||
onReset: () => this.resetForm(),
|
||||
forcedVideoKey: videoKey,
|
||||
forcedVideoUrl: videoPublicUrl,
|
||||
forcedTorrentKey: torrentKey,
|
||||
forcedTorrentUrl: torrentPublicUrl,
|
||||
});
|
||||
}
|
||||
|
||||
async generateTorrentMetadata({ file, videoPublicUrl } = {}) {
|
||||
this.isUploading = true;
|
||||
this.submitButton.disabled = true;
|
||||
this.updateUploadStatus("Calculating Info Hash...", "info");
|
||||
this.updateProgress(0);
|
||||
|
||||
// Helper used by immediate upload flow
|
||||
let infoHash = "";
|
||||
let torrentFile = null;
|
||||
|
||||
@@ -917,13 +1015,16 @@ export class UploadModal {
|
||||
if (!confirm("Magnet-only uploads require active seeding. Proceed?")) return;
|
||||
}
|
||||
|
||||
await this.publish(metadata);
|
||||
}
|
||||
|
||||
async publish(metadata) {
|
||||
// Normalize & Publish
|
||||
const { payload, errors } = normalizeVideoNotePayload(metadata);
|
||||
if (errors.length) {
|
||||
throw new Error(getVideoNoteErrorMessage(errors[0]));
|
||||
}
|
||||
|
||||
// Simulate async publish
|
||||
this.submitButton.disabled = true;
|
||||
this.submitButton.textContent = "Publishing...";
|
||||
|
||||
@@ -948,7 +1049,15 @@ export class UploadModal {
|
||||
this.toggles.comments.checked = true;
|
||||
this.toggles.nsfw.checked = false;
|
||||
this.toggles.kids.checked = false;
|
||||
this.updateProgress(null);
|
||||
|
||||
// Reset State
|
||||
this.videoUploadState = { status: 'idle', progress: 0, url: '', key: '', file: null };
|
||||
this.thumbnailUploadState = { status: 'idle', progress: 0, url: '', key: '', file: null };
|
||||
this.torrentState = { status: 'idle', infoHash: '', magnet: '', url: '', file: null };
|
||||
|
||||
this.sourceSections.progress.classList.add("hidden");
|
||||
this.sourceSections.thumbnailProgress.classList.add("hidden");
|
||||
this.sourceSections.results.classList.add("hidden");
|
||||
|
||||
// Reset thumbnail UI
|
||||
if (this.inputs.thumbnail) {
|
||||
|
||||
Reference in new issue
Block a user