From 83d7b93d0398ad0b5d8294dfb257591fa54d2c93 Mon Sep 17 00:00:00 2001 From: imattau Date: Sat, 3 Jan 2026 20:57:13 +1100 Subject: [PATCH 01/14] Add Peertube import endpoint --- app.js | 493 ++++++++++++++++++++++++++++++++++++++++++++++++++--- index.html | 77 ++++++++- styles.css | 31 +++- 3 files changed, 573 insertions(+), 28 deletions(-) diff --git a/app.js b/app.js index 2ebe738..d034187 100644 --- a/app.js +++ b/app.js @@ -7637,12 +7637,14 @@ function createNip71VideoEvent(videoData) { // Generate or use existing d-tag for parameterized replaceable event const dTag = videoData.dTag || generateVideoDTag(); + const extraTags = Array.isArray(videoData.extraTags) ? videoData.extraTags : []; const tags = [ ['d', dTag], ['title', videoData.title], createImetaTag(videoData), ['t', 'pv69420'], // Keep our app identifier for easy filtering ...videoData.tags.map(tag => ['t', tag]), + ...extraTags, ['client', 'Plebs'] ]; @@ -7667,9 +7669,15 @@ function createNip71VideoEvent(videoData) { } // Add legacy tags for broader compatibility - tags.push(['x', videoData.hash]); - tags.push(['url', videoData.url]); - tags.push(['m', videoData.type || 'video/mp4']); + if (videoData.hash) { + tags.push(['x', videoData.hash]); + } + if (videoData.url) { + tags.push(['url', videoData.url]); + } + if (videoData.type) { + tags.push(['m', videoData.type]); + } tags.push(['size', (videoData.size || 0).toString()]); tags.push(['duration', Math.floor(videoData.duration || 0).toString()]); @@ -7683,19 +7691,33 @@ function createNip71VideoEvent(videoData) { // Create a kind 1 video event (for backwards compatibility) function createKind1VideoEvent(videoData, addressableEventId = null) { + const extraTags = Array.isArray(videoData.extraTags) ? videoData.extraTags : []; const tags = [ ['title', videoData.title], ['t', 'pv69420'], ...videoData.tags.map(tag => ['t', tag]), - ['x', videoData.hash], - ['url', videoData.url], - ['m', videoData.type || 'video/mp4'], - ['size', (videoData.size || 0).toString()], - ['duration', Math.floor(videoData.duration || 0).toString()], - ['thumb', videoData.thumbnail], + ...extraTags, ['client', 'Plebs'] ]; + if (videoData.hash) { + tags.push(['x', videoData.hash]); + } + if (videoData.url) { + tags.push(['url', videoData.url]); + } + if (videoData.type) { + tags.push(['m', videoData.type]); + } + tags.push(['size', (videoData.size || 0).toString()]); + tags.push(['duration', Math.floor(videoData.duration || 0).toString()]); + if (videoData.thumbnail) { + tags.push(['thumb', videoData.thumbnail]); + } + if (videoData.preview) { + tags.push(['preview', videoData.preview]); + } + if (videoData.isNSFW) { tags.push(['content-warning', 'nsfw']); } @@ -7727,12 +7749,14 @@ function createLegacyNip71VideoEvent(videoData) { const isShort = isVideoShort(videoData.width, videoData.height, videoData.duration); const kind = isShort ? NIP71_SHORT_KIND_LEGACY : NIP71_VIDEO_KIND_LEGACY; + const extraTags = Array.isArray(videoData.extraTags) ? videoData.extraTags : []; const tags = [ ['d', videoData.dTag || generateVideoDTag()], ['title', videoData.title], createImetaTag(videoData), ['t', 'pv69420'], ...videoData.tags.map(tag => ['t', tag]), + ...extraTags, ['client', 'Plebs'] ]; @@ -7757,9 +7781,15 @@ function createLegacyNip71VideoEvent(videoData) { } // Add legacy tags for broader compatibility - tags.push(['x', videoData.hash]); - tags.push(['url', videoData.url]); - tags.push(['m', videoData.type || 'video/mp4']); + if (videoData.hash) { + tags.push(['x', videoData.hash]); + } + if (videoData.url) { + tags.push(['url', videoData.url]); + } + if (videoData.type) { + tags.push(['m', videoData.type]); + } tags.push(['size', (videoData.size || 0).toString()]); tags.push(['duration', Math.floor(videoData.duration || 0).toString()]); @@ -21648,20 +21678,7 @@ document.addEventListener('DOMContentLoaded', () => { throw new Error('Failed to publish to any relay'); } - // Link all events in our cache for reaction/zap merging - const eventIds = [signedAddressableEvent.id, signedLegacyNip71Event.id, signedKind1Event.id]; - for (const id1 of eventIds) { - for (const id2 of eventIds) { - if (id1 !== id2) { - videoEventLinks.set(id1, id2); - } - } - } - - // Store all events in allEvents cache - allEvents.set(signedAddressableEvent.id, signedAddressableEvent); - allEvents.set(signedLegacyNip71Event.id, signedLegacyNip71Event); - allEvents.set(signedKind1Event.id, signedKind1Event); + finalizePublishedVideoEvents(signedAddressableEvent, signedLegacyNip71Event, signedKind1Event); const isShort = isVideoShort(videoDimensions.width, videoDimensions.height, videoDuration); if (publishText) { @@ -34533,6 +34550,426 @@ function showCreateModal() { document.getElementById('createModal').classList.add('active'); } +const peertubeImportState = { + metadata: null, + lastFetched: null +}; + +function showPeertubeModal() { + hideCreateModal(); + if (!currentUser) { + ensureLoggedIn(); + return; + } + resetPeertubeImportForm(); + document.getElementById('peertubeModal').classList.add('active'); +} + +function hidePeertubeModal() { + document.getElementById('peertubeModal').classList.remove('active'); +} + +function backToCreateModalFromPeertube() { + hidePeertubeModal(); + showCreateModal(); +} + +function resetPeertubeImportForm() { + const form = document.getElementById('peertubeImportForm'); + if (form) { + form.reset(); + } + peertubeImportState.metadata = null; + peertubeImportState.lastFetched = null; + const statusEl = document.getElementById('peertubeMetaStatus'); + if (statusEl) { + statusEl.textContent = ''; + statusEl.classList.remove('success', 'error', 'info'); + } + const preview = document.getElementById('peertubePreview'); + if (preview) { + preview.innerHTML = ''; + } +} + +function setPeertubeMetaStatus(message, type = 'info') { + const statusEl = document.getElementById('peertubeMetaStatus'); + if (!statusEl) return; + statusEl.textContent = message; + ['success', 'error', 'info'].forEach(cls => statusEl.classList.remove(cls)); + if (type) { + statusEl.classList.add(type); + } +} + +async function fetchPeertubeMetadata() { + const urlInput = document.getElementById('peertubeUrl'); + if (!urlInput) return; + const url = urlInput.value.trim(); + if (!url) { + setPeertubeMetaStatus('Enter a Peertube URL to fetch metadata.', 'error'); + return; + } + + const parsed = parsePeertubeVideoUrl(url); + if (!parsed || !parsed.id) { + setPeertubeMetaStatus('Could not determine the video ID. Please check the URL.', 'error'); + return; + } + + setPeertubeMetaStatus('Fetching metadata from the instance…', 'info'); + try { + const response = await fetch(`${parsed.origin}/api/v1/videos/${parsed.id}`); + if (!response.ok) { + throw new Error(`Status ${response.status}`); + } + const data = await response.json(); + peertubeImportState.metadata = data; + peertubeImportState.lastFetched = Date.now(); + + const titleInput = document.getElementById('peertubeTitle'); + const descriptionInput = document.getElementById('peertubeDescription'); + const tagsInput = document.getElementById('peertubeTags'); + const authorInput = document.getElementById('peertubeAuthor'); + const thumbnailInput = document.getElementById('peertubeThumbnail'); + + if (titleInput && data.name) { + titleInput.value = data.name; + } + if (descriptionInput && data.description) { + descriptionInput.value = data.description; + } + if (tagsInput && Array.isArray(data.tags)) { + tagsInput.value = data.tags.join(','); + } + + if (authorInput) { + const owner = data.account || data.owner || data.user; + let creator = ''; + if (owner?.displayName) { + creator = owner.displayName; + } else if (owner?.username) { + creator = owner.username; + } + if (owner?.host) { + creator = creator ? `${creator}@${owner.host}` : `${owner.username}@${owner.host}`; + } + if (creator) { + authorInput.value = creator; + } + } + + if (thumbnailInput) { + const thumb = data.snapshotUrl || data.thumbnail || data.previewUrl; + if (thumb) { + thumbnailInput.value = thumb; + } + } + + const preview = document.getElementById('peertubePreview'); + if (preview) { + preview.innerHTML = ` + ${data.name || 'Peertube Video'} +
+ ${data.description ? data.description.slice(0, 150) + (data.description.length > 150 ? '…' : '') : 'No description available.'} +
+
+ Instance: ${parsed.host || parsed.origin} +
+ `; + } + + setPeertubeMetaStatus(`Metadata loaded from ${parsed.origin}`, 'success'); + } catch (error) { + console.error('Peertube metadata fetch failed:', error); + setPeertubeMetaStatus('Unable to fetch metadata (CORS or network). Fill fields manually if needed.', 'error'); + } +} + +function parsePeertubeVideoUrl(value) { + try { + const parsedUrl = new URL(value); + const segments = parsedUrl.pathname.split('/').filter(Boolean); + let videoId = null; + const watchIndex = segments.indexOf('watch'); + const videosIndex = segments.indexOf('videos'); + + if (watchIndex !== -1 && segments.length > watchIndex + 1) { + videoId = segments[watchIndex + 1]; + } else if (segments.length > 0) { + videoId = segments[segments.length - 1]; + } + + return { + origin: parsedUrl.origin, + host: parsedUrl.host, + id: videoId + }; + } catch (e) { + return null; + } +} + +async function handlePeertubeImport(e) { + e.preventDefault(); + if (!currentUser) { + ensureLoggedIn(); + return; + } + + const url = document.getElementById('peertubeUrl')?.value.trim(); + const title = document.getElementById('peertubeTitle')?.value.trim(); + const description = document.getElementById('peertubeDescription')?.value.trim(); + const tagsValue = document.getElementById('peertubeTags')?.value || ''; + const author = document.getElementById('peertubeAuthor')?.value.trim(); + const nostr = document.getElementById('peertubeNostr')?.value.trim(); + const magnet = document.getElementById('peertubeMagnet')?.value.trim(); + const allowTorrent = document.getElementById('peertubeAllowWebTorrent')?.checked; + const thumbnail = document.getElementById('peertubeThumbnail')?.value.trim(); + + if (!url || !title) { + alert('Please provide both a Peertube URL and a title.'); + return; + } + + const parsed = parsePeertubeVideoUrl(url); + if (!parsed || !parsed.id) { + alert('Invalid Peertube URL. Please double-check the link.'); + return; + } + + const tags = tagsValue.split(',').map(tag => tag.trim()).filter(tag => tag); + + const importData = { + url, + title, + description, + tags, + author, + nostr, + magnet, + allowTorrent, + thumbnail, + metadata: peertubeImportState.metadata, + parsedInstance: parsed.origin, + parsedHost: parsed.host, + videoId: parsed.id + }; + + try { + await publishPeertubeVideo(importData); + showToast('Peertube video imported successfully!', 'success'); + setTimeout(() => { + resetPeertubeImportForm(); + hidePeertubeModal(); + navigateTo('/my-videos'); + }, 1500); + } catch (error) { + console.error('Peertube import failed:', error); + showToast(error.message || 'Failed to import Peertube video.', 'error'); + } +} + +// Adds published video events to caches and links them together +function finalizePublishedVideoEvents(addressableEvent, legacyEvent, kind1Event) { + const eventIds = [addressableEvent.id, legacyEvent.id, kind1Event.id]; + for (const id1 of eventIds) { + for (const id2 of eventIds) { + if (id1 !== id2) { + videoEventLinks.set(id1, id2); + } + } + } + + allEvents.set(addressableEvent.id, addressableEvent); + allEvents.set(legacyEvent.id, legacyEvent); + allEvents.set(kind1Event.id, kind1Event); +} + +async function publishPeertubeVideo(importData) { + const submitButton = document.querySelector('#peertubeImportForm button[type="submit"]'); + const buttonText = submitButton ? submitButton.textContent : ''; + if (submitButton) { + submitButton.disabled = true; + submitButton.textContent = 'Importing…'; + } + + setPeertubeMetaStatus('Publishing Peertube video to Nostr…', 'info'); + + try { + const videoData = buildPeertubeVideoData(importData); + videoData.dTag = generateVideoDTag(); + + const addressableEvent = createNip71VideoEvent(videoData); + const signedAddressableEvent = await signEvent(addressableEvent); + + const legacyNip71Event = createLegacyNip71VideoEvent(videoData); + const signedLegacyEvent = await signEvent(legacyNip71Event); + + const kind1Event = createKind1VideoEvent(videoData, signedAddressableEvent.id); + const signedKind1Event = await signEvent(kind1Event); + + const [addressablePublished, legacyPublished, kind1Published] = await Promise.all([ + publishEvent(signedAddressableEvent), + publishEvent(signedLegacyEvent), + publishEvent(signedKind1Event) + ]); + + if (!addressablePublished && !legacyPublished && !kind1Published) { + throw new Error('Failed to publish to any relay'); + } + + finalizePublishedVideoEvents(signedAddressableEvent, signedLegacyEvent, signedKind1Event); + setPeertubeMetaStatus('Peertube video published successfully!', 'success'); + } catch (error) { + setPeertubeMetaStatus(error.message || 'Failed to publish Peertube video.', 'error'); + throw error; + } finally { + if (submitButton) { + submitButton.disabled = false; + submitButton.textContent = buttonText; + } + } +} + +function buildPeertubeVideoData(importData) { + const metadata = importData.metadata || {}; + const primaryFile = selectPeertubePrimaryFile(metadata); + const streamUrl = primaryFile?.url || metadata.streamingUrl || metadata.streamUrl || importData.url; + const fallbackSourceUrls = Array.from(new Set([ + ...(metadata.files || []).map(file => file.url).filter(Boolean), + ...(metadata.sourceFiles || []).map(file => file.url).filter(Boolean), + metadata.streamingUrl, + metadata.streamUrl + ].filter(Boolean))); + const fallbackUrls = fallbackSourceUrls.filter(url => url !== streamUrl); + const mirrors = fallbackUrls.map(url => ({ url })); + const tags = (importData.tags || []).map(tag => tag.toLowerCase()).filter(tag => tag); + if (!tags.includes('peertube')) { + tags.push('peertube'); + } + const duration = Math.floor(metadata.duration || primaryFile?.duration || 0); + const size = primaryFile?.size || metadata.fileSize || 0; + const width = primaryFile?.width || metadata.width || 0; + const height = primaryFile?.height || metadata.height || 0; + const hash = primaryFile?.sha256 || metadata.hash || metadata.sha256 || ''; + const preview = metadata.previewUrl || metadata.snapshotUrl || ''; + const thumbnail = importData.thumbnail || metadata.snapshotUrl || metadata.thumbnail || ''; + + return { + title: importData.title, + description: importData.description, + url: streamUrl, + thumbnail: thumbnail, + preview: preview, + duration: duration, + size: size, + type: primaryFile?.mime || metadata.mime || 'video/mp4', + width: width, + height: height, + mirrors: mirrors, + fallbackUrls: fallbackUrls, + tags: tags, + isNSFW: false, + hash: hash, + extraTags: buildPeertubeExtraTags(importData, metadata) + }; +} + +function selectPeertubePrimaryFile(metadata) { + if (!metadata) return null; + + const candidates = []; + if (Array.isArray(metadata.files)) { + candidates.push(...metadata.files); + } + if (Array.isArray(metadata.sourceFiles)) { + candidates.push(...metadata.sourceFiles); + } + + const validFiles = candidates.filter(file => file && file.url); + if (!validFiles.length) { + return null; + } + + validFiles.sort((a, b) => { + const widthDiff = (b.width || 0) - (a.width || 0); + if (widthDiff !== 0) return widthDiff; + return (b.size || 0) - (a.size || 0); + }); + + return validFiles[0]; +} + +function buildPeertubeExtraTags(importData, metadata) { + const tags = [['source', 'peertube']]; + + if (importData.parsedInstance) { + tags.push(['peertube-instance', importData.parsedInstance]); + } + if (importData.videoId) { + tags.push(['peertube-video-id', importData.videoId]); + } + if (importData.url) { + tags.push(['peertube-watch', importData.url]); + } + const account = metadata.account || metadata.owner || metadata.user || null; + let accountCreator = ''; + if (account) { + const usernamePart = account.username || ''; + const hostPart = account.host ? `@${account.host}` : ''; + if (usernamePart || hostPart) { + accountCreator = `${usernamePart}${hostPart}`; + } + } + const creator = importData.author || accountCreator; + if (creator) { + tags.push(['peertube-author', creator]); + } + if (metadata.account?.nip05) { + tags.push(['peertube-nip05', metadata.account.nip05]); + } + const normalizedPubkey = normalizeNostrPubkey(importData.nostr); + if (normalizedPubkey) { + tags.push(['p', normalizedPubkey]); + tags.push(['peertube-nostr', normalizedPubkey]); + } else if (importData.nostr) { + tags.push(['peertube-nostr-raw', importData.nostr]); + } + + if (importData.allowTorrent) { + tags.push(['peertube-allow-webtorrent', 'true']); + if (importData.magnet) { + tags.push(['peertube-magnet', importData.magnet]); + } + } + + return tags; +} + +function normalizeNostrPubkey(value) { + if (!value) return null; + const trimmed = value.trim(); + + try { + const decoded = window.NostrTools.nip19.decode(trimmed); + if (decoded?.type === 'npub') { + return decoded.data; + } + if (decoded?.type === 'nprofile' && decoded?.data?.pubkey) { + return decoded.data.pubkey; + } + } catch (error) { + // ignore + } + + if (/^[0-9A-Fa-f]{64}$/.test(trimmed)) { + return trimmed.toLowerCase(); + } + + return null; +} + function hideCreateModal() { document.getElementById('createModal').classList.remove('active'); } @@ -34681,6 +35118,10 @@ document.addEventListener('DOMContentLoaded', () => { if (goLiveForm) { goLiveForm.addEventListener('submit', handleGoLive); } + const peertubeForm = document.getElementById('peertubeImportForm'); + if (peertubeForm) { + peertubeForm.addEventListener('submit', handlePeertubeImport); + } }); async function handleGoLive(e) { diff --git a/index.html b/index.html index 63dc3ec..23fe0db 100644 --- a/index.html +++ b/index.html @@ -1047,7 +1047,82 @@

Create

Upload a vertical video (under 60s) + + + + + + + + @@ -1672,4 +1747,4 @@

Confirm Action

- \ No newline at end of file + diff --git a/styles.css b/styles.css index f92c1ba..c2562f2 100644 --- a/styles.css +++ b/styles.css @@ -1932,6 +1932,35 @@ nostr-zap { cursor: pointer; } +.peertube-preview { + margin-top: 1rem; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.4; +} + +.peertube-preview strong { + display: block; + color: var(--text-primary); + margin-bottom: 0.35rem; +} + +#peertubeMetaStatus.success { + color: var(--success-color); +} + +#peertubeMetaStatus.error { + color: var(--error-color); +} + +#peertubeMetaStatus.info { + color: var(--text-secondary); +} + /* Required field indicator */ .required-indicator { color: #f44336; @@ -10056,4 +10085,4 @@ body:has(.live-chat-sidebar) .relay-status-dropdown { .analytics-video-stats .stat-item { font-size: 0.75rem; } -} \ No newline at end of file +} From e8a441ca4e6222e1c6fdbb12dbce4331e8d3b611 Mon Sep 17 00:00:00 2001 From: imattau Date: Sat, 3 Jan 2026 21:11:42 +1100 Subject: [PATCH 02/14] Gate Peertube playback behind WebTorrent consent --- app.js | 270 +++++++++++++++++++++++++++++++++++++++++++++++++++-- embed.html | 270 ++++++++++++++++++++++++++++++++++++++++++++++++++++- index.html | 3 + styles.css | 67 +++++++++++++ 4 files changed, 602 insertions(+), 8 deletions(-) diff --git a/app.js b/app.js index d034187..6d592fd 100644 --- a/app.js +++ b/app.js @@ -23,6 +23,8 @@ const SUPPORTED_LANGUAGES = { // Current language (default to English, will be updated on init) let currentLanguage = 'en'; +let webTorrentClient = null; +let activeWebTorrentSession = null; // Translations object - organized by language code const translations = { @@ -7834,6 +7836,87 @@ function getVideoKindsForType(preferShorts) { } } +// Extract Peertube metadata encoded in event tags (based on our import pipeline) +function extractPeertubeInfo(tags) { + if (!tags || !tags.length) { + return null; + } + + const info = { + source: null, + instance: '', + videoId: '', + watchUrl: '', + author: '', + nip05: '', + nostr: '', + nostrRaw: '', + nostrPubkey: '', + allowWebTorrent: false, + magnet: '' + }; + + for (const tag of tags) { + const key = tag[0]; + const value = tag[1] || ''; + + switch (key) { + case 'source': + info.source = value; + break; + case 'peertube-instance': + info.instance = value; + break; + case 'peertube-video-id': + info.videoId = value; + break; + case 'peertube-watch': + info.watchUrl = value; + break; + case 'peertube-author': + info.author = value; + break; + case 'peertube-nip05': + info.nip05 = value; + break; + case 'peertube-nostr': + info.nostr = value; + break; + case 'peertube-nostr-raw': + info.nostrRaw = value; + break; + case 'peertube-allow-webtorrent': + info.allowWebTorrent = value?.toLowerCase() === 'true'; + break; + case 'peertube-magnet': + info.magnet = value; + break; + case 'p': + if (!info.nostrPubkey && value) { + info.nostrPubkey = value; + } + break; + } + } + + if (info.source !== 'peertube') { + return null; + } + + return { + source: 'peertube', + instance: info.instance, + videoId: info.videoId, + watchUrl: info.watchUrl, + author: info.author, + nip05: info.nip05, + nostr: info.nostr || info.nostrRaw, + nostrPubkey: info.nostrPubkey || null, + allowWebTorrent: info.allowWebTorrent, + magnet: info.magnet + }; +} + // Parse NIP-71 video event (kind 34235/34236 or legacy 21/22) function parseNip71VideoEvent(event) { if (!isNip71Kind(event.kind)) { @@ -7865,7 +7948,8 @@ function parseNip71VideoEvent(event) { dTag: '', publishedAt: event.created_at, isShort: isNip71ShortKind(event.kind), - kind: event.kind + kind: event.kind, + peertube: null }; for (const tag of tags) { @@ -7972,11 +8056,13 @@ function parseNip71VideoEvent(event) { videoData.tags.push(tag[1]); } break; - case 'content-warning': - videoData.isNSFW = tag[1] === 'nsfw'; - break; - } + case 'content-warning': + videoData.isNSFW = tag[1] === 'nsfw'; + break; } +} + + videoData.peertube = extractPeertubeInfo(tags); return videoData.title ? videoData : null; } @@ -21789,8 +21875,131 @@ window.addEventListener('beforeunload', () => { ws.close(); } }); + cleanupWebTorrentSession(); }); +function ensureWebTorrentClient() { + if (!window.WebTorrent) { + return null; + } + if (!webTorrentClient) { + webTorrentClient = new WebTorrent(); + } + return webTorrentClient; +} + +function cleanupWebTorrentSession() { + if (activeWebTorrentSession?.torrent) { + try { + activeWebTorrentSession.torrent.destroy(); + } catch (error) { + console.error('Failed to destroy WebTorrent session:', error); + } + } + activeWebTorrentSession = null; +} + +function selectWebTorrentFile(files) { + if (!files || !files.length) { + return null; + } + + const preferredExtensions = ['mp4', 'webm', 'mkv', 'mov']; + for (const ext of preferredExtensions) { + const match = files.find(file => file.name?.toLowerCase().endsWith(`.${ext}`)); + if (match) return match; + } + + return files[0]; +} + +async function startPeertubeWebTorrentStream(eventId, magnet, videoElement) { + if (!magnet) { + throw new Error('Magnet link missing'); + } + + const client = ensureWebTorrentClient(); + if (!client) { + throw new Error('WebTorrent is not available in this browser'); + } + + cleanupWebTorrentSession(); + + return new Promise((resolve, reject) => { + try { + const torrent = client.add(magnet, (torrent) => { + const file = selectWebTorrentFile(torrent.files); + if (!file) { + reject(new Error('No playable file found inside the torrent')); + return; + } + + activeWebTorrentSession = { eventId, torrent }; + + file.renderTo(videoElement, { autoplay: true }, (error) => { + if (error) { + reject(error); + } else { + resolve(file); + } + }); + }); + + torrent.on('error', (err) => { + reject(err); + }); + } catch (error) { + reject(error); + } + }); +} + +async function handlePeertubeWebTorrent(eventId, magnet) { + const videoElement = document.querySelector('.video-player video'); + const consentEl = document.getElementById(`webtorrent-consent-${eventId}`); + const button = document.getElementById(`webtorrent-btn-${eventId}`); + + if (!videoElement || !magnet) { + return; + } + + if (button) { + button.disabled = true; + button.textContent = 'Starting WebTorrent…'; + } + if (consentEl) { + consentEl.classList.add('webtorrent-pending'); + } + + videoElement.pause(); + videoElement.removeAttribute('src'); + videoElement.load(); + + try { + await startPeertubeWebTorrentStream(eventId, magnet, videoElement); + if (button) { + button.textContent = 'Streaming via WebTorrent'; + } + if (consentEl) { + consentEl.classList.add('webtorrent-active'); + consentEl.classList.remove('webtorrent-pending'); + } + const loadingState = document.getElementById(`video-loading-${eventId}`); + if (loadingState) { + loadingState.style.display = 'none'; + } + } catch (error) { + console.error('WebTorrent stream failed:', error); + if (button) { + button.disabled = false; + button.textContent = 'Stream via WebTorrent'; + } + if (consentEl) { + consentEl.classList.remove('webtorrent-pending'); + } + showToast(error.message || 'WebTorrent streaming failed', 'error'); + } +} // Function to load trending videos with streaming async function loadTrendingVideos(period = 'today') { const now = Math.floor(Date.now() / 1000); @@ -30223,6 +30432,8 @@ async function playVideo(eventId, skipNSFWCheck = false, skipRatioedCheck = fals sidebarPlaceholder = null; } + cleanupWebTorrentSession(); + const mainContent = document.getElementById('mainContent'); // Clean up any existing video element to prevent audio from continuing to play @@ -30268,6 +30479,9 @@ async function playVideo(eventId, skipNSFWCheck = false, skipRatioedCheck = fals videoData.eventId = event.id; videoData.eventKind = event.kind; + const peertubeInfo = videoData.peertube; + const canStartWebTorrent = !!(peertubeInfo?.allowWebTorrent && peertubeInfo.magnet); + const profile = await fetchUserProfile(event.pubkey); const avatarUrl = profile?.picture || profile?.avatar || ''; const nip05 = profile?.nip05 || ''; @@ -30331,6 +30545,27 @@ async function playVideo(eventId, skipNSFWCheck = false, skipRatioedCheck = fals const userNpub = currentUser ? window.NostrTools.nip19.npubEncode(currentUser.pubkey) : ''; const displayName = profile?.name || profile?.display_name || `User ${event.pubkey.slice(0, 8)}`; + const peertubeWatchUrl = peertubeInfo?.watchUrl ? escapeHtml(peertubeInfo.watchUrl) : ''; + const peertubeInstanceLabel = peertubeInfo?.instance ? ` (${escapeHtml(peertubeInfo.instance)})` : ''; + const peertubeAuthorLabel = peertubeInfo?.author ? `by ${escapeHtml(peertubeInfo.author)}` : ''; + const nostrLinkHtml = peertubeInfo?.nostrPubkey ? `Mapped to Nostr` : ''; + const peertubeBadgeHtml = peertubeInfo ? ` +
+ ${peertubeInfo.author ? peertubeAuthorLabel : 'Original on Peertube'} + ${peertubeWatchUrl ? `View on Peertube` : ''} + ${peertubeInfo.instance ? `${peertubeInstanceLabel}` : ''} + ${nostrLinkHtml} +
+ ` : ''; + const webTorrentConsentHtml = canStartWebTorrent ? ` + + ` : ''; // Render page immediately with video loading state - video URL is resolved async mainContent.innerHTML = ` @@ -30343,6 +30578,7 @@ async function playVideo(eventId, skipNSFWCheck = false, skipRatioedCheck = fals + ${webTorrentConsentHtml}
@@ -30383,6 +30619,7 @@ async function playVideo(eventId, skipNSFWCheck = false, skipRatioedCheck = fals ` : ''}
+ ${peertubeBadgeHtml}
+ + diff --git a/index.html b/index.html index 23fe0db..fd04f85 100644 --- a/index.html +++ b/index.html @@ -1736,6 +1736,9 @@

Confirm Action

+ + + diff --git a/styles.css b/styles.css index c2562f2..47021ff 100644 --- a/styles.css +++ b/styles.css @@ -850,6 +850,73 @@ header { height: 100%; } +.video-player .webtorrent-consent { + position: absolute; + bottom: 16px; + left: 16px; + right: 16px; + background: rgba(0, 0, 0, 0.72); + border-radius: 12px; + padding: 0.75rem 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + color: #fff; + font-size: 0.9rem; + z-index: 6; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.video-player .webtorrent-consent p { + margin: 0; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.85); +} + +.webtorrent-btn { + background: #00b894; + border: none; + color: white; + padding: 0.45rem 0.9rem; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease; +} + +.webtorrent-btn:disabled { + background: var(--border); + cursor: not-allowed; +} + +.webtorrent-consent.webtorrent-active { + border-color: var(--success-color); +} + +.peertube-badge { + margin-top: 0.5rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.video-info .peertube-badge a { + color: var(--accent); + text-decoration: underline; +} + +.video-info .peertube-badge .peertube-instance { + color: rgba(255, 255, 255, 0.7); +} + +.video-info .peertube-badge .peertube-nostr a { + color: var(--success-color); +} + .video-loading-state { position: absolute; top: 0; From dcdf8a0f24e8da54c33e1bcb023bc69ed93ad182 Mon Sep 17 00:00:00 2001 From: imattau Date: Sat, 3 Jan 2026 21:16:59 +1100 Subject: [PATCH 03/14] Auto fetch Peertube metadata on URL input --- app.js | 41 +++++++++++++++++++++++++++++++++++++---- index.html | 5 ++++- styles.css | 25 +++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/app.js b/app.js index 6d592fd..cfb173a 100644 --- a/app.js +++ b/app.js @@ -34808,10 +34808,13 @@ function showCreateModal() { document.getElementById('createModal').classList.add('active'); } -const peertubeImportState = { - metadata: null, - lastFetched: null -}; + const peertubeImportState = { + metadata: null, + lastFetched: null + }; + let peertubeAutoFetchTimer = null; + let lastPeertubeUrlRequested = ''; + let peertubeAutoFetchInitialized = false; function showPeertubeModal() { hideCreateModal(); @@ -34821,6 +34824,7 @@ function showPeertubeModal() { } resetPeertubeImportForm(); document.getElementById('peertubeModal').classList.add('active'); + setupPeertubeUrlAutoFetch(); } function hidePeertubeModal() { @@ -34839,6 +34843,7 @@ function resetPeertubeImportForm() { } peertubeImportState.metadata = null; peertubeImportState.lastFetched = null; + lastPeertubeUrlRequested = ''; const statusEl = document.getElementById('peertubeMetaStatus'); if (statusEl) { statusEl.textContent = ''; @@ -34860,6 +34865,32 @@ function setPeertubeMetaStatus(message, type = 'info') { } } +function setupPeertubeUrlAutoFetch() { + const urlInput = document.getElementById('peertubeUrl'); + if (!urlInput) return; + + if (peertubeAutoFetchInitialized) { + return; + } + peertubeAutoFetchInitialized = true; + + urlInput.addEventListener('input', () => { + clearTimeout(peertubeAutoFetchTimer); + const value = urlInput.value.trim(); + if (!value) { + setPeertubeMetaStatus(''); + lastPeertubeUrlRequested = ''; + return; + } + + peertubeAutoFetchTimer = setTimeout(() => { + if (value && value !== lastPeertubeUrlRequested) { + fetchPeertubeMetadata(); + } + }, 600); + }); +} + async function fetchPeertubeMetadata() { const urlInput = document.getElementById('peertubeUrl'); if (!urlInput) return; @@ -34875,6 +34906,8 @@ async function fetchPeertubeMetadata() { return; } + lastPeertubeUrlRequested = url; + setPeertubeMetaStatus('Fetching metadata from the instance…', 'info'); try { const response = await fetch(`${parsed.origin}/api/v1/videos/${parsed.id}`); diff --git a/index.html b/index.html index fd04f85..35e8a1e 100644 --- a/index.html +++ b/index.html @@ -1079,7 +1079,10 @@

Import from Peertube

- +
+ + +
Plebs will attempt to fetch metadata and attribute the author.
diff --git a/styles.css b/styles.css index 47021ff..971a658 100644 --- a/styles.css +++ b/styles.css @@ -2188,6 +2188,31 @@ nostr-zap { gap: 0.5rem; } +.input-with-action { + display: flex; + gap: 0.5rem; +} + +.input-with-action .form-control { + flex: 1; +} + +.fetch-metadata-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--text-primary); + padding: 0 0.75rem; + border-radius: 999px; + cursor: pointer; + align-self: center; + font-weight: 500; + white-space: nowrap; +} + +.fetch-metadata-btn:hover { + border-color: var(--text-primary); +} + .form-group label { font-weight: 500; } From dd8f6ce281005bd9a95f365e0cf19b0797be623a Mon Sep 17 00:00:00 2001 From: imattau Date: Sat, 3 Jan 2026 21:18:51 +1100 Subject: [PATCH 04/14] Ensure Peertube imports always supply hash --- app.js | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/app.js b/app.js index cfb173a..cbc12ef 100644 --- a/app.js +++ b/app.js @@ -35088,7 +35088,7 @@ async function publishPeertubeVideo(importData) { setPeertubeMetaStatus('Publishing Peertube video to Nostr…', 'info'); try { - const videoData = buildPeertubeVideoData(importData); + const videoData = await buildPeertubeVideoData(importData); videoData.dTag = generateVideoDTag(); const addressableEvent = createNip71VideoEvent(videoData); @@ -35123,7 +35123,7 @@ async function publishPeertubeVideo(importData) { } } -function buildPeertubeVideoData(importData) { +async function buildPeertubeVideoData(importData) { const metadata = importData.metadata || {}; const primaryFile = selectPeertubePrimaryFile(metadata); const streamUrl = primaryFile?.url || metadata.streamingUrl || metadata.streamUrl || importData.url; @@ -35143,7 +35143,7 @@ function buildPeertubeVideoData(importData) { const size = primaryFile?.size || metadata.fileSize || 0; const width = primaryFile?.width || metadata.width || 0; const height = primaryFile?.height || metadata.height || 0; - const hash = primaryFile?.sha256 || metadata.hash || metadata.sha256 || ''; + const hash = await derivePeertubeHash(importData, metadata, primaryFile?.sha256); const preview = metadata.previewUrl || metadata.snapshotUrl || ''; const thumbnail = importData.thumbnail || metadata.snapshotUrl || metadata.thumbnail || ''; @@ -35261,6 +35261,25 @@ function normalizeNostrPubkey(value) { return null; } +async function derivePeertubeHash(importData, metadata, primaryHash) { + if (primaryHash) return primaryHash; + const candidate = metadata.hash || metadata.sha256 || metadata.videoHash || ''; + if (candidate) return candidate; + + const fallbackInput = importData.url || metadata.watchUrl || metadata.streamUrl || metadata.snapshotUrl || ''; + if (!fallbackInput) return ''; + + try { + const encoder = new TextEncoder(); + const data = encoder.encode(fallbackInput); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(hashBuffer)).map(byte => byte.toString(16).padStart(2, '0')).join(''); + } catch (error) { + console.error('Failed to derive Peertube hash:', error); + return ''; + } +} + function hideCreateModal() { document.getElementById('createModal').classList.remove('active'); } From 26954c286ca30c792741ed83545ea0dc1b82be16 Mon Sep 17 00:00:00 2001 From: imattau Date: Sat, 3 Jan 2026 23:01:41 +1100 Subject: [PATCH 05/14] Improve Peertube import resilience --- app.js | 271 +++++++++++++++++++++++++++++++++++++++++++++++++++-- index.html | 10 +- styles.css | 9 ++ 3 files changed, 276 insertions(+), 14 deletions(-) diff --git a/app.js b/app.js index cbc12ef..b11b7d9 100644 --- a/app.js +++ b/app.js @@ -21973,7 +21973,6 @@ async function handlePeertubeWebTorrent(eventId, magnet) { videoElement.pause(); videoElement.removeAttribute('src'); - videoElement.load(); try { await startPeertubeWebTorrentStream(eventId, magnet, videoElement); @@ -25106,9 +25105,19 @@ async function loadFollowing() { // Load my videos with streaming async function loadMyVideos() { + console.log('[My Videos] loadMyVideos triggered (currentUser?):', currentUser?.pubkey || 'none'); if (!currentUser) { - await checkStoredLogin(); // Wait for login check + if (loginCheckPromise) { + console.log('[My Videos] waiting for loginCheckPromise...'); + await loginCheckPromise; + console.log('[My Videos] loginCheckPromise resolved, currentUser:', currentUser?.pubkey); + } else { + console.log('[My Videos] no loginCheckPromise, falling back to checkStoredLogin()'); + await checkStoredLogin(); + console.log('[My Videos] checkStoredLogin finished, currentUser:', currentUser?.pubkey); + } if (!currentUser) { + console.log('[My Videos] still no user after login attempt, showing login prompt'); document.getElementById('mainContent').innerHTML = `

${t('empty.loginToViewVideos')}

`; return; } @@ -34810,7 +34819,9 @@ function showCreateModal() { const peertubeImportState = { metadata: null, - lastFetched: null + lastFetched: null, + streamUrlOverride: '', + magnet: '' }; let peertubeAutoFetchTimer = null; let lastPeertubeUrlRequested = ''; @@ -34843,6 +34854,7 @@ function resetPeertubeImportForm() { } peertubeImportState.metadata = null; peertubeImportState.lastFetched = null; + peertubeImportState.streamUrlOverride = ''; lastPeertubeUrlRequested = ''; const statusEl = document.getElementById('peertubeMetaStatus'); if (statusEl) { @@ -34853,6 +34865,22 @@ function resetPeertubeImportForm() { if (preview) { preview.innerHTML = ''; } + const streamInput = document.getElementById('peertubeStreamUrl'); + if (streamInput) { + streamInput.value = ''; + } + const testBtn = document.getElementById('peertubeTestStreamBtn'); + if (testBtn) { + testBtn.disabled = true; + } + peertubeImportState.magnet = ''; + updatePeertubeWebTorrentHint('WebTorrent will only run if a magnet is available.', 'info'); + configurePeertubeWebTorrentCheckbox(false); + const testerVideo = document.getElementById('peertubeStreamTester'); + if (testerVideo) { + testerVideo.pause(); + testerVideo.removeAttribute('src'); + } } function setPeertubeMetaStatus(message, type = 'info') { @@ -34865,6 +34893,25 @@ function setPeertubeMetaStatus(message, type = 'info') { } } +function updatePeertubeWebTorrentHint(message, type = 'info') { + const hintEl = document.getElementById('peertubeWebTorrentHint'); + if (!hintEl) return; + hintEl.textContent = message; + ['success', 'error', 'info'].forEach(cls => hintEl.classList.remove(cls)); + if (type) { + hintEl.classList.add(type); + } +} + +function configurePeertubeWebTorrentCheckbox(enabled) { + const checkbox = document.getElementById('peertubeAllowWebTorrent'); + if (!checkbox) return; + checkbox.disabled = !enabled; + if (!enabled) { + checkbox.checked = false; + } +} + function setupPeertubeUrlAutoFetch() { const urlInput = document.getElementById('peertubeUrl'); if (!urlInput) return; @@ -34895,6 +34942,7 @@ async function fetchPeertubeMetadata() { const urlInput = document.getElementById('peertubeUrl'); if (!urlInput) return; const url = urlInput.value.trim(); + console.log('[Peertube] fetch metadata requested:', url); if (!url) { setPeertubeMetaStatus('Enter a Peertube URL to fetch metadata.', 'error'); return; @@ -34909,14 +34957,23 @@ async function fetchPeertubeMetadata() { lastPeertubeUrlRequested = url; setPeertubeMetaStatus('Fetching metadata from the instance…', 'info'); + const slowTimer = setTimeout(() => { + setPeertubeMetaStatus('Peertube is responding slowly—still waiting for metadata...', 'info'); + }, PEERTUBE_METADATA_SLOW_THRESHOLD_MS); + let response; try { - const response = await fetch(`${parsed.origin}/api/v1/videos/${parsed.id}`); + response = await fetch(`${parsed.origin}/api/v1/videos/${parsed.id}`); if (!response.ok) { throw new Error(`Status ${response.status}`); } const data = await response.json(); peertubeImportState.metadata = data; peertubeImportState.lastFetched = Date.now(); + const statusNote = response.status === 206 + ? ' (Partial content response)' + : response.status !== 200 + ? ` (Status ${response.status})` + : ''; const titleInput = document.getElementById('peertubeTitle'); const descriptionInput = document.getElementById('peertubeDescription'); @@ -34970,11 +35027,204 @@ async function fetchPeertubeMetadata() { `; } - setPeertubeMetaStatus(`Metadata loaded from ${parsed.origin}`, 'success'); + setPeertubeMetaStatus(`Metadata loaded from ${parsed.origin}${statusNote}`, 'success'); + console.log('[Peertube] metadata loaded:', parsed.origin, parsed.id, data); + const testBtn = document.getElementById('peertubeTestStreamBtn'); + if (testBtn) { + testBtn.disabled = false; + } + const extractedMagnet = findPeertubeMagnet(data); + peertubeImportState.magnet = extractedMagnet; + if (extractedMagnet) { + updatePeertubeWebTorrentHint('Magnet detected automatically for WebTorrent playback.', 'success'); + configurePeertubeWebTorrentCheckbox(true); + } else { + updatePeertubeWebTorrentHint('No magnet/torrent provided by this instance yet.', 'info'); + configurePeertubeWebTorrentCheckbox(false); + } } catch (error) { console.error('Peertube metadata fetch failed:', error); - setPeertubeMetaStatus('Unable to fetch metadata (CORS or network). Fill fields manually if needed.', 'error'); + setPeertubeMetaStatus(`Unable to fetch metadata (${error.message}). Fill fields manually if needed.`, 'error'); + const testBtn = document.getElementById('peertubeTestStreamBtn'); + if (testBtn) { + testBtn.disabled = true; + } + updatePeertubeWebTorrentHint('Unable to determine torrent metadata while fetching.', 'error'); + configurePeertubeWebTorrentCheckbox(false); + peertubeImportState.magnet = ''; + } finally { + clearTimeout(slowTimer); + } +} + +const PEERTUBE_STREAM_SKIP_EXTENSIONS = ['.m3u8']; +const PEERTUBE_METADATA_SLOW_THRESHOLD_MS = 4000; + +function gatherPeertubeStreamCandidates(metadata) { + if (!metadata) return []; + + const seen = new Set(); + const candidates = []; + + const addCandidate = (url, label = '') => { + if (!url || typeof url !== 'string') return; + const trimmed = url.trim(); + if (!trimmed || seen.has(trimmed)) return; + const cleanPath = trimmed.split('?')[0].split('#')[0]; + const extensionIndex = cleanPath.lastIndexOf('.'); + const extension = extensionIndex !== -1 ? cleanPath.substring(extensionIndex).toLowerCase() : ''; + if (PEERTUBE_STREAM_SKIP_EXTENSIONS.includes(extension)) return; + seen.add(trimmed); + candidates.push({ url: trimmed, label }); + }; + + const addFileVariants = (file, prefixLabel) => { + if (!file) return; + const resolution = file.resolution?.label || file.resolution?.id || 'stream'; + const baseLabel = prefixLabel ? `${resolution} · ${prefixLabel}` : resolution; + addCandidate(file.fileUrl, baseLabel); + addCandidate(file.fileDownloadUrl, baseLabel); + if (file?.playlistUrl) { + addCandidate(file.playlistUrl, `${baseLabel} (playlist)`); + } + }; + + if (Array.isArray(metadata.files)) { + metadata.files.forEach(file => addFileVariants(file, 'metadata file')); + } + if (Array.isArray(metadata.sourceFiles)) { + metadata.sourceFiles.forEach(file => addFileVariants(file, 'source file')); } + if (Array.isArray(metadata.streamingPlaylists)) { + metadata.streamingPlaylists.forEach(playlist => { + if (!playlist || !Array.isArray(playlist.files)) return; + playlist.files.forEach(file => { + addFileVariants(file, 'streaming playlist'); + }); + }); + } + + addCandidate(metadata.streamingUrl, 'streamingUrl'); + addCandidate(metadata.streamUrl, 'streamUrl'); + + return candidates; +} + +function findPeertubeMagnet(metadata) { + if (!metadata) return ''; + if (Array.isArray(metadata.streamingPlaylists)) { + for (const playlist of metadata.streamingPlaylists) { + const files = playlist?.files || []; + for (const file of files) { + if (file?.magnetUri) { + return file.magnetUri; + } + if (file?.torrentUrl) { + return file.torrentUrl; + } + if (file?.torrentDownloadUrl) { + return file.torrentDownloadUrl; + } + } + } + } + if (Array.isArray(metadata.files)) { + for (const file of metadata.files) { + if (file?.magnetUri) { + return file.magnetUri; + } + } + } + return ''; +} + +function probePeertubeStreamUrl(videoElement, url, timeoutMs = 12000) { + return new Promise((resolve, reject) => { + let settled = false; + const settle = (success, error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + videoElement.oncanplay = null; + videoElement.onloadedmetadata = null; + videoElement.onerror = null; + videoElement.pause(); + videoElement.removeAttribute('src'); + if (success) { + resolve(url); + } else { + reject(error); + } + }; + + const timer = setTimeout(() => { + settle(false, new Error('Timed out while checking stream')); + }, timeoutMs); + + videoElement.oncanplay = () => settle(true); + videoElement.onloadedmetadata = () => settle(true); + videoElement.onerror = () => settle(false, new Error('Stream load failed')); + + videoElement.src = url; + videoElement.load(); + }); +} + +async function testPeertubeStream() { + const metadata = peertubeImportState.metadata; + if (!metadata) { + setPeertubeMetaStatus('Fetch metadata first to discover candidate streams.', 'error'); + return; + } + + const candidates = gatherPeertubeStreamCandidates(metadata); + if (!candidates.length) { + setPeertubeMetaStatus('No direct MP4/WebM URLs detected in the metadata.', 'error'); + return; + } + + const testerVideo = document.getElementById('peertubeStreamTester'); + if (!testerVideo) return; + + const testBtn = document.getElementById('peertubeTestStreamBtn'); + if (testBtn) { + testBtn.disabled = true; + testBtn.textContent = 'Testing…'; + } + + let detectedCandidate = null; + try { + for (let i = 0; i < candidates.length; i++) { + const candidate = candidates[i]; + setPeertubeMetaStatus(`Testing stream ${candidate.label || (i + 1)}/${candidates.length}…`, 'info'); + try { + await probePeertubeStreamUrl(testerVideo, candidate.url); + detectedCandidate = candidate; + break; + } catch (error) { + console.warn('Peertube stream test failed:', error); + } + } + } finally { + if (testBtn) { + testBtn.disabled = false; + testBtn.textContent = 'Test stream'; + } + testerVideo.pause(); + testerVideo.removeAttribute('src'); + } + + if (detectedCandidate) { + setPeertubeMetaStatus(`Playable stream found: ${detectedCandidate.url} ${detectedCandidate.label ? `(${detectedCandidate.label})` : ''}`, 'success'); + peertubeImportState.streamUrlOverride = detectedCandidate.url; + const streamInput = document.getElementById('peertubeStreamUrl'); + if (streamInput) { + streamInput.value = detectedCandidate.url; + } + return; + } + + setPeertubeMetaStatus('No playable HTTP streams detected; you can still rely on WebTorrent.', 'error'); } function parsePeertubeVideoUrl(value) { @@ -35014,9 +35264,10 @@ async function handlePeertubeImport(e) { const tagsValue = document.getElementById('peertubeTags')?.value || ''; const author = document.getElementById('peertubeAuthor')?.value.trim(); const nostr = document.getElementById('peertubeNostr')?.value.trim(); - const magnet = document.getElementById('peertubeMagnet')?.value.trim(); + const magnet = peertubeImportState.magnet; const allowTorrent = document.getElementById('peertubeAllowWebTorrent')?.checked; const thumbnail = document.getElementById('peertubeThumbnail')?.value.trim(); + const streamUrlOverride = document.getElementById('peertubeStreamUrl')?.value.trim(); if (!url || !title) { alert('Please provide both a Peertube URL and a title.'); @@ -35042,6 +35293,7 @@ async function handlePeertubeImport(e) { allowTorrent, thumbnail, metadata: peertubeImportState.metadata, + streamUrl: streamUrlOverride, parsedInstance: parsed.origin, parsedHost: parsed.host, videoId: parsed.id @@ -35050,6 +35302,7 @@ async function handlePeertubeImport(e) { try { await publishPeertubeVideo(importData); showToast('Peertube video imported successfully!', 'success'); + console.log('[Peertube] import success:', importData.url, importData.magnet); setTimeout(() => { resetPeertubeImportForm(); hidePeertubeModal(); @@ -35126,7 +35379,9 @@ async function publishPeertubeVideo(importData) { async function buildPeertubeVideoData(importData) { const metadata = importData.metadata || {}; const primaryFile = selectPeertubePrimaryFile(metadata); - const streamUrl = primaryFile?.url || metadata.streamingUrl || metadata.streamUrl || importData.url; + const explicitStreamUrl = importData.streamUrl; + const fallbackStreamUrl = primaryFile?.url || metadata.streamingUrl || metadata.streamUrl || importData.url; + const streamUrl = explicitStreamUrl || fallbackStreamUrl; const fallbackSourceUrls = Array.from(new Set([ ...(metadata.files || []).map(file => file.url).filter(Boolean), ...(metadata.sourceFiles || []).map(file => file.url).filter(Boolean), diff --git a/index.html b/index.html index 35e8a1e..605cead 100644 --- a/index.html +++ b/index.html @@ -1085,11 +1085,6 @@

Import from Peertube

Plebs will attempt to fetch metadata and attribute the author. -
- - -

Used only if you allow WebTorrent playback.

-
@@ -1118,13 +1113,16 @@

Import from Peertube

+
- +
+ +
diff --git a/styles.css b/styles.css index 971a658..5236547 100644 --- a/styles.css +++ b/styles.css @@ -2213,6 +2213,15 @@ nostr-zap { border-color: var(--text-primary); } +.test-stream-btn { + font-weight: 500; +} + +.test-stream-btn[disabled] { + opacity: 0.6; + cursor: not-allowed; +} + .form-group label { font-weight: 500; } From 5a95195727264ffb6e5d9e5808e9ea498d2f9dcc Mon Sep 17 00:00:00 2001 From: imattau Date: Sat, 3 Jan 2026 23:13:51 +1100 Subject: [PATCH 06/14] Modularize Peertube import flow --- app.js | 722 --------------------------------------------------- index.html | 1 + peertube.js | 734 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 735 insertions(+), 722 deletions(-) create mode 100644 peertube.js diff --git a/app.js b/app.js index b11b7d9..74b1091 100644 --- a/app.js +++ b/app.js @@ -34817,724 +34817,6 @@ function showCreateModal() { document.getElementById('createModal').classList.add('active'); } - const peertubeImportState = { - metadata: null, - lastFetched: null, - streamUrlOverride: '', - magnet: '' - }; - let peertubeAutoFetchTimer = null; - let lastPeertubeUrlRequested = ''; - let peertubeAutoFetchInitialized = false; - -function showPeertubeModal() { - hideCreateModal(); - if (!currentUser) { - ensureLoggedIn(); - return; - } - resetPeertubeImportForm(); - document.getElementById('peertubeModal').classList.add('active'); - setupPeertubeUrlAutoFetch(); -} - -function hidePeertubeModal() { - document.getElementById('peertubeModal').classList.remove('active'); -} - -function backToCreateModalFromPeertube() { - hidePeertubeModal(); - showCreateModal(); -} - -function resetPeertubeImportForm() { - const form = document.getElementById('peertubeImportForm'); - if (form) { - form.reset(); - } - peertubeImportState.metadata = null; - peertubeImportState.lastFetched = null; - peertubeImportState.streamUrlOverride = ''; - lastPeertubeUrlRequested = ''; - const statusEl = document.getElementById('peertubeMetaStatus'); - if (statusEl) { - statusEl.textContent = ''; - statusEl.classList.remove('success', 'error', 'info'); - } - const preview = document.getElementById('peertubePreview'); - if (preview) { - preview.innerHTML = ''; - } - const streamInput = document.getElementById('peertubeStreamUrl'); - if (streamInput) { - streamInput.value = ''; - } - const testBtn = document.getElementById('peertubeTestStreamBtn'); - if (testBtn) { - testBtn.disabled = true; - } - peertubeImportState.magnet = ''; - updatePeertubeWebTorrentHint('WebTorrent will only run if a magnet is available.', 'info'); - configurePeertubeWebTorrentCheckbox(false); - const testerVideo = document.getElementById('peertubeStreamTester'); - if (testerVideo) { - testerVideo.pause(); - testerVideo.removeAttribute('src'); - } -} - -function setPeertubeMetaStatus(message, type = 'info') { - const statusEl = document.getElementById('peertubeMetaStatus'); - if (!statusEl) return; - statusEl.textContent = message; - ['success', 'error', 'info'].forEach(cls => statusEl.classList.remove(cls)); - if (type) { - statusEl.classList.add(type); - } -} - -function updatePeertubeWebTorrentHint(message, type = 'info') { - const hintEl = document.getElementById('peertubeWebTorrentHint'); - if (!hintEl) return; - hintEl.textContent = message; - ['success', 'error', 'info'].forEach(cls => hintEl.classList.remove(cls)); - if (type) { - hintEl.classList.add(type); - } -} - -function configurePeertubeWebTorrentCheckbox(enabled) { - const checkbox = document.getElementById('peertubeAllowWebTorrent'); - if (!checkbox) return; - checkbox.disabled = !enabled; - if (!enabled) { - checkbox.checked = false; - } -} - -function setupPeertubeUrlAutoFetch() { - const urlInput = document.getElementById('peertubeUrl'); - if (!urlInput) return; - - if (peertubeAutoFetchInitialized) { - return; - } - peertubeAutoFetchInitialized = true; - - urlInput.addEventListener('input', () => { - clearTimeout(peertubeAutoFetchTimer); - const value = urlInput.value.trim(); - if (!value) { - setPeertubeMetaStatus(''); - lastPeertubeUrlRequested = ''; - return; - } - - peertubeAutoFetchTimer = setTimeout(() => { - if (value && value !== lastPeertubeUrlRequested) { - fetchPeertubeMetadata(); - } - }, 600); - }); -} - -async function fetchPeertubeMetadata() { - const urlInput = document.getElementById('peertubeUrl'); - if (!urlInput) return; - const url = urlInput.value.trim(); - console.log('[Peertube] fetch metadata requested:', url); - if (!url) { - setPeertubeMetaStatus('Enter a Peertube URL to fetch metadata.', 'error'); - return; - } - - const parsed = parsePeertubeVideoUrl(url); - if (!parsed || !parsed.id) { - setPeertubeMetaStatus('Could not determine the video ID. Please check the URL.', 'error'); - return; - } - - lastPeertubeUrlRequested = url; - - setPeertubeMetaStatus('Fetching metadata from the instance…', 'info'); - const slowTimer = setTimeout(() => { - setPeertubeMetaStatus('Peertube is responding slowly—still waiting for metadata...', 'info'); - }, PEERTUBE_METADATA_SLOW_THRESHOLD_MS); - let response; - try { - response = await fetch(`${parsed.origin}/api/v1/videos/${parsed.id}`); - if (!response.ok) { - throw new Error(`Status ${response.status}`); - } - const data = await response.json(); - peertubeImportState.metadata = data; - peertubeImportState.lastFetched = Date.now(); - const statusNote = response.status === 206 - ? ' (Partial content response)' - : response.status !== 200 - ? ` (Status ${response.status})` - : ''; - - const titleInput = document.getElementById('peertubeTitle'); - const descriptionInput = document.getElementById('peertubeDescription'); - const tagsInput = document.getElementById('peertubeTags'); - const authorInput = document.getElementById('peertubeAuthor'); - const thumbnailInput = document.getElementById('peertubeThumbnail'); - - if (titleInput && data.name) { - titleInput.value = data.name; - } - if (descriptionInput && data.description) { - descriptionInput.value = data.description; - } - if (tagsInput && Array.isArray(data.tags)) { - tagsInput.value = data.tags.join(','); - } - - if (authorInput) { - const owner = data.account || data.owner || data.user; - let creator = ''; - if (owner?.displayName) { - creator = owner.displayName; - } else if (owner?.username) { - creator = owner.username; - } - if (owner?.host) { - creator = creator ? `${creator}@${owner.host}` : `${owner.username}@${owner.host}`; - } - if (creator) { - authorInput.value = creator; - } - } - - if (thumbnailInput) { - const thumb = data.snapshotUrl || data.thumbnail || data.previewUrl; - if (thumb) { - thumbnailInput.value = thumb; - } - } - - const preview = document.getElementById('peertubePreview'); - if (preview) { - preview.innerHTML = ` - ${data.name || 'Peertube Video'} -
- ${data.description ? data.description.slice(0, 150) + (data.description.length > 150 ? '…' : '') : 'No description available.'} -
-
- Instance: ${parsed.host || parsed.origin} -
- `; - } - - setPeertubeMetaStatus(`Metadata loaded from ${parsed.origin}${statusNote}`, 'success'); - console.log('[Peertube] metadata loaded:', parsed.origin, parsed.id, data); - const testBtn = document.getElementById('peertubeTestStreamBtn'); - if (testBtn) { - testBtn.disabled = false; - } - const extractedMagnet = findPeertubeMagnet(data); - peertubeImportState.magnet = extractedMagnet; - if (extractedMagnet) { - updatePeertubeWebTorrentHint('Magnet detected automatically for WebTorrent playback.', 'success'); - configurePeertubeWebTorrentCheckbox(true); - } else { - updatePeertubeWebTorrentHint('No magnet/torrent provided by this instance yet.', 'info'); - configurePeertubeWebTorrentCheckbox(false); - } - } catch (error) { - console.error('Peertube metadata fetch failed:', error); - setPeertubeMetaStatus(`Unable to fetch metadata (${error.message}). Fill fields manually if needed.`, 'error'); - const testBtn = document.getElementById('peertubeTestStreamBtn'); - if (testBtn) { - testBtn.disabled = true; - } - updatePeertubeWebTorrentHint('Unable to determine torrent metadata while fetching.', 'error'); - configurePeertubeWebTorrentCheckbox(false); - peertubeImportState.magnet = ''; - } finally { - clearTimeout(slowTimer); - } -} - -const PEERTUBE_STREAM_SKIP_EXTENSIONS = ['.m3u8']; -const PEERTUBE_METADATA_SLOW_THRESHOLD_MS = 4000; - -function gatherPeertubeStreamCandidates(metadata) { - if (!metadata) return []; - - const seen = new Set(); - const candidates = []; - - const addCandidate = (url, label = '') => { - if (!url || typeof url !== 'string') return; - const trimmed = url.trim(); - if (!trimmed || seen.has(trimmed)) return; - const cleanPath = trimmed.split('?')[0].split('#')[0]; - const extensionIndex = cleanPath.lastIndexOf('.'); - const extension = extensionIndex !== -1 ? cleanPath.substring(extensionIndex).toLowerCase() : ''; - if (PEERTUBE_STREAM_SKIP_EXTENSIONS.includes(extension)) return; - seen.add(trimmed); - candidates.push({ url: trimmed, label }); - }; - - const addFileVariants = (file, prefixLabel) => { - if (!file) return; - const resolution = file.resolution?.label || file.resolution?.id || 'stream'; - const baseLabel = prefixLabel ? `${resolution} · ${prefixLabel}` : resolution; - addCandidate(file.fileUrl, baseLabel); - addCandidate(file.fileDownloadUrl, baseLabel); - if (file?.playlistUrl) { - addCandidate(file.playlistUrl, `${baseLabel} (playlist)`); - } - }; - - if (Array.isArray(metadata.files)) { - metadata.files.forEach(file => addFileVariants(file, 'metadata file')); - } - if (Array.isArray(metadata.sourceFiles)) { - metadata.sourceFiles.forEach(file => addFileVariants(file, 'source file')); - } - if (Array.isArray(metadata.streamingPlaylists)) { - metadata.streamingPlaylists.forEach(playlist => { - if (!playlist || !Array.isArray(playlist.files)) return; - playlist.files.forEach(file => { - addFileVariants(file, 'streaming playlist'); - }); - }); - } - - addCandidate(metadata.streamingUrl, 'streamingUrl'); - addCandidate(metadata.streamUrl, 'streamUrl'); - - return candidates; -} - -function findPeertubeMagnet(metadata) { - if (!metadata) return ''; - if (Array.isArray(metadata.streamingPlaylists)) { - for (const playlist of metadata.streamingPlaylists) { - const files = playlist?.files || []; - for (const file of files) { - if (file?.magnetUri) { - return file.magnetUri; - } - if (file?.torrentUrl) { - return file.torrentUrl; - } - if (file?.torrentDownloadUrl) { - return file.torrentDownloadUrl; - } - } - } - } - if (Array.isArray(metadata.files)) { - for (const file of metadata.files) { - if (file?.magnetUri) { - return file.magnetUri; - } - } - } - return ''; -} - -function probePeertubeStreamUrl(videoElement, url, timeoutMs = 12000) { - return new Promise((resolve, reject) => { - let settled = false; - const settle = (success, error) => { - if (settled) return; - settled = true; - clearTimeout(timer); - videoElement.oncanplay = null; - videoElement.onloadedmetadata = null; - videoElement.onerror = null; - videoElement.pause(); - videoElement.removeAttribute('src'); - if (success) { - resolve(url); - } else { - reject(error); - } - }; - - const timer = setTimeout(() => { - settle(false, new Error('Timed out while checking stream')); - }, timeoutMs); - - videoElement.oncanplay = () => settle(true); - videoElement.onloadedmetadata = () => settle(true); - videoElement.onerror = () => settle(false, new Error('Stream load failed')); - - videoElement.src = url; - videoElement.load(); - }); -} - -async function testPeertubeStream() { - const metadata = peertubeImportState.metadata; - if (!metadata) { - setPeertubeMetaStatus('Fetch metadata first to discover candidate streams.', 'error'); - return; - } - - const candidates = gatherPeertubeStreamCandidates(metadata); - if (!candidates.length) { - setPeertubeMetaStatus('No direct MP4/WebM URLs detected in the metadata.', 'error'); - return; - } - - const testerVideo = document.getElementById('peertubeStreamTester'); - if (!testerVideo) return; - - const testBtn = document.getElementById('peertubeTestStreamBtn'); - if (testBtn) { - testBtn.disabled = true; - testBtn.textContent = 'Testing…'; - } - - let detectedCandidate = null; - try { - for (let i = 0; i < candidates.length; i++) { - const candidate = candidates[i]; - setPeertubeMetaStatus(`Testing stream ${candidate.label || (i + 1)}/${candidates.length}…`, 'info'); - try { - await probePeertubeStreamUrl(testerVideo, candidate.url); - detectedCandidate = candidate; - break; - } catch (error) { - console.warn('Peertube stream test failed:', error); - } - } - } finally { - if (testBtn) { - testBtn.disabled = false; - testBtn.textContent = 'Test stream'; - } - testerVideo.pause(); - testerVideo.removeAttribute('src'); - } - - if (detectedCandidate) { - setPeertubeMetaStatus(`Playable stream found: ${detectedCandidate.url} ${detectedCandidate.label ? `(${detectedCandidate.label})` : ''}`, 'success'); - peertubeImportState.streamUrlOverride = detectedCandidate.url; - const streamInput = document.getElementById('peertubeStreamUrl'); - if (streamInput) { - streamInput.value = detectedCandidate.url; - } - return; - } - - setPeertubeMetaStatus('No playable HTTP streams detected; you can still rely on WebTorrent.', 'error'); -} - -function parsePeertubeVideoUrl(value) { - try { - const parsedUrl = new URL(value); - const segments = parsedUrl.pathname.split('/').filter(Boolean); - let videoId = null; - const watchIndex = segments.indexOf('watch'); - const videosIndex = segments.indexOf('videos'); - - if (watchIndex !== -1 && segments.length > watchIndex + 1) { - videoId = segments[watchIndex + 1]; - } else if (segments.length > 0) { - videoId = segments[segments.length - 1]; - } - - return { - origin: parsedUrl.origin, - host: parsedUrl.host, - id: videoId - }; - } catch (e) { - return null; - } -} - -async function handlePeertubeImport(e) { - e.preventDefault(); - if (!currentUser) { - ensureLoggedIn(); - return; - } - - const url = document.getElementById('peertubeUrl')?.value.trim(); - const title = document.getElementById('peertubeTitle')?.value.trim(); - const description = document.getElementById('peertubeDescription')?.value.trim(); - const tagsValue = document.getElementById('peertubeTags')?.value || ''; - const author = document.getElementById('peertubeAuthor')?.value.trim(); - const nostr = document.getElementById('peertubeNostr')?.value.trim(); - const magnet = peertubeImportState.magnet; - const allowTorrent = document.getElementById('peertubeAllowWebTorrent')?.checked; - const thumbnail = document.getElementById('peertubeThumbnail')?.value.trim(); - const streamUrlOverride = document.getElementById('peertubeStreamUrl')?.value.trim(); - - if (!url || !title) { - alert('Please provide both a Peertube URL and a title.'); - return; - } - - const parsed = parsePeertubeVideoUrl(url); - if (!parsed || !parsed.id) { - alert('Invalid Peertube URL. Please double-check the link.'); - return; - } - - const tags = tagsValue.split(',').map(tag => tag.trim()).filter(tag => tag); - - const importData = { - url, - title, - description, - tags, - author, - nostr, - magnet, - allowTorrent, - thumbnail, - metadata: peertubeImportState.metadata, - streamUrl: streamUrlOverride, - parsedInstance: parsed.origin, - parsedHost: parsed.host, - videoId: parsed.id - }; - - try { - await publishPeertubeVideo(importData); - showToast('Peertube video imported successfully!', 'success'); - console.log('[Peertube] import success:', importData.url, importData.magnet); - setTimeout(() => { - resetPeertubeImportForm(); - hidePeertubeModal(); - navigateTo('/my-videos'); - }, 1500); - } catch (error) { - console.error('Peertube import failed:', error); - showToast(error.message || 'Failed to import Peertube video.', 'error'); - } -} - -// Adds published video events to caches and links them together -function finalizePublishedVideoEvents(addressableEvent, legacyEvent, kind1Event) { - const eventIds = [addressableEvent.id, legacyEvent.id, kind1Event.id]; - for (const id1 of eventIds) { - for (const id2 of eventIds) { - if (id1 !== id2) { - videoEventLinks.set(id1, id2); - } - } - } - - allEvents.set(addressableEvent.id, addressableEvent); - allEvents.set(legacyEvent.id, legacyEvent); - allEvents.set(kind1Event.id, kind1Event); -} - -async function publishPeertubeVideo(importData) { - const submitButton = document.querySelector('#peertubeImportForm button[type="submit"]'); - const buttonText = submitButton ? submitButton.textContent : ''; - if (submitButton) { - submitButton.disabled = true; - submitButton.textContent = 'Importing…'; - } - - setPeertubeMetaStatus('Publishing Peertube video to Nostr…', 'info'); - - try { - const videoData = await buildPeertubeVideoData(importData); - videoData.dTag = generateVideoDTag(); - - const addressableEvent = createNip71VideoEvent(videoData); - const signedAddressableEvent = await signEvent(addressableEvent); - - const legacyNip71Event = createLegacyNip71VideoEvent(videoData); - const signedLegacyEvent = await signEvent(legacyNip71Event); - - const kind1Event = createKind1VideoEvent(videoData, signedAddressableEvent.id); - const signedKind1Event = await signEvent(kind1Event); - - const [addressablePublished, legacyPublished, kind1Published] = await Promise.all([ - publishEvent(signedAddressableEvent), - publishEvent(signedLegacyEvent), - publishEvent(signedKind1Event) - ]); - - if (!addressablePublished && !legacyPublished && !kind1Published) { - throw new Error('Failed to publish to any relay'); - } - - finalizePublishedVideoEvents(signedAddressableEvent, signedLegacyEvent, signedKind1Event); - setPeertubeMetaStatus('Peertube video published successfully!', 'success'); - } catch (error) { - setPeertubeMetaStatus(error.message || 'Failed to publish Peertube video.', 'error'); - throw error; - } finally { - if (submitButton) { - submitButton.disabled = false; - submitButton.textContent = buttonText; - } - } -} - -async function buildPeertubeVideoData(importData) { - const metadata = importData.metadata || {}; - const primaryFile = selectPeertubePrimaryFile(metadata); - const explicitStreamUrl = importData.streamUrl; - const fallbackStreamUrl = primaryFile?.url || metadata.streamingUrl || metadata.streamUrl || importData.url; - const streamUrl = explicitStreamUrl || fallbackStreamUrl; - const fallbackSourceUrls = Array.from(new Set([ - ...(metadata.files || []).map(file => file.url).filter(Boolean), - ...(metadata.sourceFiles || []).map(file => file.url).filter(Boolean), - metadata.streamingUrl, - metadata.streamUrl - ].filter(Boolean))); - const fallbackUrls = fallbackSourceUrls.filter(url => url !== streamUrl); - const mirrors = fallbackUrls.map(url => ({ url })); - const tags = (importData.tags || []).map(tag => tag.toLowerCase()).filter(tag => tag); - if (!tags.includes('peertube')) { - tags.push('peertube'); - } - const duration = Math.floor(metadata.duration || primaryFile?.duration || 0); - const size = primaryFile?.size || metadata.fileSize || 0; - const width = primaryFile?.width || metadata.width || 0; - const height = primaryFile?.height || metadata.height || 0; - const hash = await derivePeertubeHash(importData, metadata, primaryFile?.sha256); - const preview = metadata.previewUrl || metadata.snapshotUrl || ''; - const thumbnail = importData.thumbnail || metadata.snapshotUrl || metadata.thumbnail || ''; - - return { - title: importData.title, - description: importData.description, - url: streamUrl, - thumbnail: thumbnail, - preview: preview, - duration: duration, - size: size, - type: primaryFile?.mime || metadata.mime || 'video/mp4', - width: width, - height: height, - mirrors: mirrors, - fallbackUrls: fallbackUrls, - tags: tags, - isNSFW: false, - hash: hash, - extraTags: buildPeertubeExtraTags(importData, metadata) - }; -} - -function selectPeertubePrimaryFile(metadata) { - if (!metadata) return null; - - const candidates = []; - if (Array.isArray(metadata.files)) { - candidates.push(...metadata.files); - } - if (Array.isArray(metadata.sourceFiles)) { - candidates.push(...metadata.sourceFiles); - } - - const validFiles = candidates.filter(file => file && file.url); - if (!validFiles.length) { - return null; - } - - validFiles.sort((a, b) => { - const widthDiff = (b.width || 0) - (a.width || 0); - if (widthDiff !== 0) return widthDiff; - return (b.size || 0) - (a.size || 0); - }); - - return validFiles[0]; -} - -function buildPeertubeExtraTags(importData, metadata) { - const tags = [['source', 'peertube']]; - - if (importData.parsedInstance) { - tags.push(['peertube-instance', importData.parsedInstance]); - } - if (importData.videoId) { - tags.push(['peertube-video-id', importData.videoId]); - } - if (importData.url) { - tags.push(['peertube-watch', importData.url]); - } - const account = metadata.account || metadata.owner || metadata.user || null; - let accountCreator = ''; - if (account) { - const usernamePart = account.username || ''; - const hostPart = account.host ? `@${account.host}` : ''; - if (usernamePart || hostPart) { - accountCreator = `${usernamePart}${hostPart}`; - } - } - const creator = importData.author || accountCreator; - if (creator) { - tags.push(['peertube-author', creator]); - } - if (metadata.account?.nip05) { - tags.push(['peertube-nip05', metadata.account.nip05]); - } - const normalizedPubkey = normalizeNostrPubkey(importData.nostr); - if (normalizedPubkey) { - tags.push(['p', normalizedPubkey]); - tags.push(['peertube-nostr', normalizedPubkey]); - } else if (importData.nostr) { - tags.push(['peertube-nostr-raw', importData.nostr]); - } - - if (importData.allowTorrent) { - tags.push(['peertube-allow-webtorrent', 'true']); - if (importData.magnet) { - tags.push(['peertube-magnet', importData.magnet]); - } - } - - return tags; -} - -function normalizeNostrPubkey(value) { - if (!value) return null; - const trimmed = value.trim(); - - try { - const decoded = window.NostrTools.nip19.decode(trimmed); - if (decoded?.type === 'npub') { - return decoded.data; - } - if (decoded?.type === 'nprofile' && decoded?.data?.pubkey) { - return decoded.data.pubkey; - } - } catch (error) { - // ignore - } - - if (/^[0-9A-Fa-f]{64}$/.test(trimmed)) { - return trimmed.toLowerCase(); - } - - return null; -} - -async function derivePeertubeHash(importData, metadata, primaryHash) { - if (primaryHash) return primaryHash; - const candidate = metadata.hash || metadata.sha256 || metadata.videoHash || ''; - if (candidate) return candidate; - - const fallbackInput = importData.url || metadata.watchUrl || metadata.streamUrl || metadata.snapshotUrl || ''; - if (!fallbackInput) return ''; - - try { - const encoder = new TextEncoder(); - const data = encoder.encode(fallbackInput); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - return Array.from(new Uint8Array(hashBuffer)).map(byte => byte.toString(16).padStart(2, '0')).join(''); - } catch (error) { - console.error('Failed to derive Peertube hash:', error); - return ''; - } -} - function hideCreateModal() { document.getElementById('createModal').classList.remove('active'); } @@ -35683,10 +34965,6 @@ document.addEventListener('DOMContentLoaded', () => { if (goLiveForm) { goLiveForm.addEventListener('submit', handleGoLive); } - const peertubeForm = document.getElementById('peertubeImportForm'); - if (peertubeForm) { - peertubeForm.addEventListener('submit', handlePeertubeImport); - } }); async function handleGoLive(e) { diff --git a/index.html b/index.html index 605cead..a8fd31a 100644 --- a/index.html +++ b/index.html @@ -1742,6 +1742,7 @@

Confirm Action

+ diff --git a/peertube.js b/peertube.js new file mode 100644 index 0000000..aa04b23 --- /dev/null +++ b/peertube.js @@ -0,0 +1,734 @@ +const peertubeImportState = { + metadata: null, + lastFetched: null, + streamUrlOverride: '', + magnet: '' +}; + +let peertubeAutoFetchTimer = null; +let lastPeertubeUrlRequested = ''; +let peertubeAutoFetchInitialized = false; + +function showPeertubeModal() { + hideCreateModal(); + if (!currentUser) { + ensureLoggedIn(); + return; + } + resetPeertubeImportForm(); + document.getElementById('peertubeModal').classList.add('active'); + setupPeertubeUrlAutoFetch(); +} + +function hidePeertubeModal() { + document.getElementById('peertubeModal').classList.remove('active'); +} + +function backToCreateModalFromPeertube() { + hidePeertubeModal(); + showCreateModal(); +} + +function resetPeertubeImportForm() { + const form = document.getElementById('peertubeImportForm'); + if (form) { + form.reset(); + } + peertubeImportState.metadata = null; + peertubeImportState.lastFetched = null; + peertubeImportState.streamUrlOverride = ''; + lastPeertubeUrlRequested = ''; + + const statusEl = document.getElementById('peertubeMetaStatus'); + if (statusEl) { + statusEl.textContent = ''; + statusEl.classList.remove('success', 'error', 'info'); + } + + const preview = document.getElementById('peertubePreview'); + if (preview) { + preview.innerHTML = ''; + } + + const streamInput = document.getElementById('peertubeStreamUrl'); + if (streamInput) { + streamInput.value = ''; + } + + const testBtn = document.getElementById('peertubeTestStreamBtn'); + if (testBtn) { + testBtn.disabled = true; + } + + peertubeImportState.magnet = ''; + updatePeertubeWebTorrentHint('WebTorrent will only run if a magnet is available.', 'info'); + configurePeertubeWebTorrentCheckbox(false); + + const testerVideo = document.getElementById('peertubeStreamTester'); + if (testerVideo) { + testerVideo.pause(); + testerVideo.removeAttribute('src'); + } +} + +function setPeertubeMetaStatus(message, type = 'info') { + const statusEl = document.getElementById('peertubeMetaStatus'); + if (!statusEl) return; + statusEl.textContent = message; + ['success', 'error', 'info'].forEach(cls => statusEl.classList.remove(cls)); + if (type) { + statusEl.classList.add(type); + } +} + +function updatePeertubeWebTorrentHint(message, type = 'info') { + const hintEl = document.getElementById('peertubeWebTorrentHint'); + if (!hintEl) return; + hintEl.textContent = message; + ['success', 'error', 'info'].forEach(cls => hintEl.classList.remove(cls)); + if (type) { + hintEl.classList.add(type); + } +} + +function configurePeertubeWebTorrentCheckbox(enabled) { + const checkbox = document.getElementById('peertubeAllowWebTorrent'); + if (!checkbox) return; + checkbox.disabled = !enabled; + if (!enabled) { + checkbox.checked = false; + } +} + +function setupPeertubeUrlAutoFetch() { + const urlInput = document.getElementById('peertubeUrl'); + if (!urlInput) return; + + if (peertubeAutoFetchInitialized) { + return; + } + peertubeAutoFetchInitialized = true; + + urlInput.addEventListener('input', () => { + clearTimeout(peertubeAutoFetchTimer); + const value = urlInput.value.trim(); + if (!value) { + setPeertubeMetaStatus(''); + lastPeertubeUrlRequested = ''; + return; + } + + peertubeAutoFetchTimer = setTimeout(() => { + if (value && value !== lastPeertubeUrlRequested) { + fetchPeertubeMetadata(); + } + }, 600); + }); +} + +async function fetchPeertubeMetadata() { + const urlInput = document.getElementById('peertubeUrl'); + if (!urlInput) return; + const url = urlInput.value.trim(); + console.log('[Peertube] fetch metadata requested:', url); + if (!url) { + setPeertubeMetaStatus('Enter a Peertube URL to fetch metadata.', 'error'); + return; + } + + const parsed = parsePeertubeVideoUrl(url); + if (!parsed || !parsed.id) { + setPeertubeMetaStatus('Could not determine the video ID. Please check the URL.', 'error'); + return; + } + + lastPeertubeUrlRequested = url; + + setPeertubeMetaStatus('Fetching metadata from the instance…', 'info'); + const slowTimer = setTimeout(() => { + setPeertubeMetaStatus('Peertube is responding slowly—still waiting for metadata...', 'info'); + }, PEERTUBE_METADATA_SLOW_THRESHOLD_MS); + + try { + const response = await fetch(`${parsed.origin}/api/v1/videos/${parsed.id}`); + if (!response.ok) { + throw new Error(`Status ${response.status}`); + } + + const data = await response.json(); + peertubeImportState.metadata = data; + peertubeImportState.lastFetched = Date.now(); + + const statusNote = response.status === 206 + ? ' (Partial content response)' + : response.status !== 200 + ? ` (Status ${response.status})` + : ''; + + const titleInput = document.getElementById('peertubeTitle'); + const descriptionInput = document.getElementById('peertubeDescription'); + const tagsInput = document.getElementById('peertubeTags'); + const authorInput = document.getElementById('peertubeAuthor'); + const thumbnailInput = document.getElementById('peertubeThumbnail'); + + if (titleInput && data.name) { + titleInput.value = data.name; + } + if (descriptionInput && data.description) { + descriptionInput.value = data.description; + } + if (tagsInput && Array.isArray(data.tags)) { + tagsInput.value = data.tags.join(','); + } + + if (authorInput) { + const owner = data.account || data.owner || data.user; + let creator = ''; + if (owner?.displayName) { + creator = owner.displayName; + } else if (owner?.username) { + creator = owner.username; + } + if (owner?.host) { + creator = creator ? `${creator}@${owner.host}` : `${owner.username}@${owner.host}`; + } + if (creator) { + authorInput.value = creator; + } + } + + if (thumbnailInput) { + const thumb = data.snapshotUrl || data.thumbnail || data.previewUrl; + if (thumb) { + thumbnailInput.value = thumb; + } + } + + const preview = document.getElementById('peertubePreview'); + if (preview) { + preview.innerHTML = ` + ${data.name || 'Peertube Video'} +
+ ${data.description ? data.description.slice(0, 150) + (data.description.length > 150 ? '…' : '') : 'No description available.'} +
+
+ Instance: ${parsed.host || parsed.origin} +
+ `; + } + + setPeertubeMetaStatus(`Metadata loaded from ${parsed.origin}${statusNote}`, 'success'); + console.log('[Peertube] metadata loaded:', parsed.origin, parsed.id, data); + + const testBtn = document.getElementById('peertubeTestStreamBtn'); + if (testBtn) { + testBtn.disabled = false; + } + + const extractedMagnet = findPeertubeMagnet(data); + peertubeImportState.magnet = extractedMagnet; + if (extractedMagnet) { + updatePeertubeWebTorrentHint('Magnet detected automatically for WebTorrent playback.', 'success'); + configurePeertubeWebTorrentCheckbox(true); + } else { + updatePeertubeWebTorrentHint('No magnet/torrent provided by this instance yet.', 'info'); + configurePeertubeWebTorrentCheckbox(false); + } + } catch (error) { + console.error('Peertube metadata fetch failed:', error); + setPeertubeMetaStatus(`Unable to fetch metadata (${error.message}). Fill fields manually if needed.`, 'error'); + const testBtn = document.getElementById('peertubeTestStreamBtn'); + if (testBtn) { + testBtn.disabled = true; + } + updatePeertubeWebTorrentHint('Unable to determine torrent metadata while fetching.', 'error'); + configurePeertubeWebTorrentCheckbox(false); + peertubeImportState.magnet = ''; + } finally { + clearTimeout(slowTimer); + } +} + +const PEERTUBE_STREAM_SKIP_EXTENSIONS = ['.m3u8']; +const PEERTUBE_METADATA_SLOW_THRESHOLD_MS = 4000; + +function gatherPeertubeStreamCandidates(metadata) { + if (!metadata) return []; + + const seen = new Set(); + const candidates = []; + + const addCandidate = (url, label = '') => { + if (!url || typeof url !== 'string') return; + const trimmed = url.trim(); + if (!trimmed || seen.has(trimmed)) return; + const cleanPath = trimmed.split('?')[0].split('#')[0]; + const extensionIndex = cleanPath.lastIndexOf('.'); + const extension = extensionIndex !== -1 ? cleanPath.substring(extensionIndex).toLowerCase() : ''; + if (PEERTUBE_STREAM_SKIP_EXTENSIONS.includes(extension)) return; + seen.add(trimmed); + candidates.push({ url: trimmed, label }); + }; + + const addFileVariants = (file, prefixLabel) => { + if (!file) return; + const resolution = file.resolution?.label || file.resolution?.id || 'stream'; + const baseLabel = prefixLabel ? `${resolution} · ${prefixLabel}` : resolution; + addCandidate(file.fileUrl, baseLabel); + addCandidate(file.fileDownloadUrl, baseLabel); + if (file?.playlistUrl) { + addCandidate(file.playlistUrl, `${baseLabel} (playlist)`); + } + }; + + if (Array.isArray(metadata.files)) { + metadata.files.forEach(file => addFileVariants(file, 'metadata file')); + } + if (Array.isArray(metadata.sourceFiles)) { + metadata.sourceFiles.forEach(file => addFileVariants(file, 'source file')); + } + if (Array.isArray(metadata.streamingPlaylists)) { + metadata.streamingPlaylists.forEach(playlist => { + if (!playlist || !Array.isArray(playlist.files)) return; + playlist.files.forEach(file => { + addFileVariants(file, 'streaming playlist'); + }); + }); + } + + addCandidate(metadata.streamingUrl, 'streamingUrl'); + addCandidate(metadata.streamUrl, 'streamUrl'); + + return candidates; +} + +function findPeertubeMagnet(metadata) { + if (!metadata) return ''; + if (Array.isArray(metadata.streamingPlaylists)) { + for (const playlist of metadata.streamingPlaylists) { + const files = playlist?.files || []; + for (const file of files) { + if (file?.magnetUri) { + return file.magnetUri; + } + if (file?.torrentUrl) { + return file.torrentUrl; + } + if (file?.torrentDownloadUrl) { + return file.torrentDownloadUrl; + } + } + } + } + if (Array.isArray(metadata.files)) { + for (const file of metadata.files) { + if (file?.magnetUri) { + return file.magnetUri; + } + } + } + return ''; +} + +function probePeertubeStreamUrl(videoElement, url, timeoutMs = 12000) { + return new Promise((resolve, reject) => { + let settled = false; + const settle = (success, error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + videoElement.oncanplay = null; + videoElement.onloadedmetadata = null; + videoElement.onerror = null; + videoElement.pause(); + videoElement.removeAttribute('src'); + if (success) { + resolve(url); + } else { + reject(error); + } + }; + + const timer = setTimeout(() => { + settle(false, new Error('Timed out while checking stream')); + }, timeoutMs); + + videoElement.oncanplay = () => settle(true); + videoElement.onloadedmetadata = () => settle(true); + videoElement.onerror = () => settle(false, new Error('Stream load failed')); + + videoElement.src = url; + videoElement.load(); + }); +} + +async function testPeertubeStream() { + const metadata = peertubeImportState.metadata; + if (!metadata) { + setPeertubeMetaStatus('Fetch metadata first to discover candidate streams.', 'error'); + return; + } + + const candidates = gatherPeertubeStreamCandidates(metadata); + if (!candidates.length) { + setPeertubeMetaStatus('No direct MP4/WebM URLs detected in the metadata.', 'error'); + return; + } + + const testerVideo = document.getElementById('peertubeStreamTester'); + if (!testerVideo) return; + + const testBtn = document.getElementById('peertubeTestStreamBtn'); + if (testBtn) { + testBtn.disabled = true; + testBtn.textContent = 'Testing…'; + } + + let detectedCandidate = null; + try { + for (let i = 0; i < candidates.length; i++) { + const candidate = candidates[i]; + setPeertubeMetaStatus(`Testing stream ${candidate.label || (i + 1)}/${candidates.length}…`, 'info'); + try { + await probePeertubeStreamUrl(testerVideo, candidate.url); + detectedCandidate = candidate; + break; + } catch (error) { + console.warn('Peertube stream test failed:', error); + } + } + } finally { + if (testBtn) { + testBtn.disabled = false; + testBtn.textContent = 'Test stream'; + } + testerVideo.pause(); + testerVideo.removeAttribute('src'); + } + + if (detectedCandidate) { + setPeertubeMetaStatus(`Playable stream found: ${detectedCandidate.url} ${detectedCandidate.label ? `(${detectedCandidate.label})` : ''}`, 'success'); + peertubeImportState.streamUrlOverride = detectedCandidate.url; + const streamInput = document.getElementById('peertubeStreamUrl'); + if (streamInput) { + streamInput.value = detectedCandidate.url; + } + return; + } + + setPeertubeMetaStatus('No playable HTTP streams detected; you can still rely on WebTorrent.', 'error'); +} + +function parsePeertubeVideoUrl(value) { + try { + const parsedUrl = new URL(value); + const segments = parsedUrl.pathname.split('/').filter(Boolean); + let videoId = null; + const watchIndex = segments.indexOf('watch'); + const videosIndex = segments.indexOf('videos'); + + if (watchIndex !== -1 && segments.length > watchIndex + 1) { + videoId = segments[watchIndex + 1]; + } else if (segments.length > 0) { + videoId = segments[segments.length - 1]; + } + + return { + origin: parsedUrl.origin, + host: parsedUrl.host, + id: videoId + }; + } catch (e) { + return null; + } +} + +async function handlePeertubeImport(e) { + e.preventDefault(); + if (!currentUser) { + ensureLoggedIn(); + return; + } + + const url = document.getElementById('peertubeUrl')?.value.trim(); + const title = document.getElementById('peertubeTitle')?.value.trim(); + const description = document.getElementById('peertubeDescription')?.value.trim(); + const tagsValue = document.getElementById('peertubeTags')?.value || ''; + const author = document.getElementById('peertubeAuthor')?.value.trim(); + const nostr = document.getElementById('peertubeNostr')?.value.trim(); + const magnet = peertubeImportState.magnet; + const allowTorrent = document.getElementById('peertubeAllowWebTorrent')?.checked; + const thumbnail = document.getElementById('peertubeThumbnail')?.value.trim(); + const streamUrlOverride = document.getElementById('peertubeStreamUrl')?.value.trim(); + + if (!url || !title) { + alert('Please provide both a Peertube URL and a title.'); + return; + } + + const parsed = parsePeertubeVideoUrl(url); + if (!parsed || !parsed.id) { + alert('Invalid Peertube URL. Please double-check the link.'); + return; + } + + const tags = tagsValue.split(',').map(tag => tag.trim()).filter(tag => tag); + + const importData = { + url, + title, + description, + tags, + author, + nostr, + magnet, + allowTorrent, + thumbnail, + metadata: peertubeImportState.metadata, + streamUrl: streamUrlOverride, + parsedInstance: parsed.origin, + parsedHost: parsed.host, + videoId: parsed.id + }; + + try { + await publishPeertubeVideo(importData); + showToast('Peertube video imported successfully!', 'success'); + console.log('[Peertube] import success:', importData.url, importData.magnet); + setTimeout(() => { + resetPeertubeImportForm(); + hidePeertubeModal(); + navigateTo('/my-videos'); + }, 1500); + } catch (error) { + console.error('Peertube import failed:', error); + showToast(error.message || 'Failed to import Peertube video.', 'error'); + } +} + +function finalizePublishedVideoEvents(addressableEvent, legacyEvent, kind1Event) { + const eventIds = [addressableEvent.id, legacyEvent.id, kind1Event.id]; + for (const id1 of eventIds) { + for (const id2 of eventIds) { + if (id1 !== id2) { + videoEventLinks.set(id1, id2); + } + } + } + + allEvents.set(addressableEvent.id, addressableEvent); + allEvents.set(legacyEvent.id, legacyEvent); + allEvents.set(kind1Event.id, kind1Event); +} + +async function publishPeertubeVideo(importData) { + const submitButton = document.querySelector('#peertubeImportForm button[type="submit"]'); + const buttonText = submitButton ? submitButton.textContent : ''; + if (submitButton) { + submitButton.disabled = true; + submitButton.textContent = 'Importing…'; + } + + setPeertubeMetaStatus('Publishing Peertube video to Nostr…', 'info'); + + try { + const videoData = await buildPeertubeVideoData(importData); + videoData.dTag = generateVideoDTag(); + + const addressableEvent = createNip71VideoEvent(videoData); + const signedAddressableEvent = await signEvent(addressableEvent); + + const legacyNip71Event = createLegacyNip71VideoEvent(videoData); + const signedLegacyEvent = await signEvent(legacyNip71Event); + + const kind1Event = createKind1VideoEvent(videoData, signedAddressableEvent.id); + const signedKind1Event = await signEvent(kind1Event); + + const [addressablePublished, legacyPublished, kind1Published] = await Promise.all([ + publishEvent(signedAddressableEvent), + publishEvent(signedLegacyEvent), + publishEvent(signedKind1Event) + ]); + + if (!addressablePublished && !legacyPublished && !kind1Published) { + throw new Error('Failed to publish to any relay'); + } + + finalizePublishedVideoEvents(signedAddressableEvent, signedLegacyEvent, signedKind1Event); + setPeertubeMetaStatus('Peertube video published successfully!', 'success'); + } catch (error) { + setPeertubeMetaStatus(error.message || 'Failed to publish Peertube video.', 'error'); + throw error; + } finally { + if (submitButton) { + submitButton.disabled = false; + submitButton.textContent = buttonText; + } + } +} + +async function buildPeertubeVideoData(importData) { + const metadata = importData.metadata || {}; + const primaryFile = selectPeertubePrimaryFile(metadata); + const explicitStreamUrl = importData.streamUrl; + const fallbackStreamUrl = primaryFile?.url || metadata.streamingUrl || metadata.streamUrl || importData.url; + const streamUrl = explicitStreamUrl || fallbackStreamUrl; + const fallbackSourceUrls = Array.from(new Set([ + ...(metadata.files || []).map(file => file.url).filter(Boolean), + ...(metadata.sourceFiles || []).map(file => file.url).filter(Boolean), + metadata.streamingUrl, + metadata.streamUrl + ].filter(Boolean))); + const fallbackUrls = fallbackSourceUrls.filter(url => url !== streamUrl); + const mirrors = fallbackUrls.map(url => ({ url })); + const tags = (importData.tags || []).map(tag => tag.toLowerCase()).filter(tag => tag); + if (!tags.includes('peertube')) { + tags.push('peertube'); + } + const duration = Math.floor(metadata.duration || primaryFile?.duration || 0); + const size = primaryFile?.size || metadata.fileSize || 0; + const width = primaryFile?.width || metadata.width || 0; + const height = primaryFile?.height || metadata.height || 0; + const hash = await derivePeertubeHash(importData, metadata, primaryFile?.sha256); + const preview = metadata.previewUrl || metadata.snapshotUrl || ''; + const thumbnail = importData.thumbnail || metadata.snapshotUrl || metadata.thumbnail || ''; + + return { + title: importData.title, + description: importData.description, + url: streamUrl, + thumbnail: thumbnail, + preview: preview, + duration: duration, + size: size, + type: primaryFile?.mime || metadata.mime || 'video/mp4', + width: width, + height: height, + mirrors: mirrors, + fallbackUrls: fallbackUrls, + tags: tags, + isNSFW: false, + hash: hash, + extraTags: buildPeertubeExtraTags(importData, metadata) + }; +} + +function selectPeertubePrimaryFile(metadata) { + if (!metadata) return null; + + const candidates = []; + if (Array.isArray(metadata.files)) { + candidates.push(...metadata.files); + } + if (Array.isArray(metadata.sourceFiles)) { + candidates.push(...metadata.sourceFiles); + } + + const validFiles = candidates.filter(file => file && file.url); + if (!validFiles.length) { + return null; + } + + validFiles.sort((a, b) => { + const widthDiff = (b.width || 0) - (a.width || 0); + if (widthDiff !== 0) return widthDiff; + return (b.size || 0) - (a.size || 0); + }); + + return validFiles[0]; +} + +function buildPeertubeExtraTags(importData, metadata) { + const tags = [['source', 'peertube']]; + + if (importData.parsedInstance) { + tags.push(['peertube-instance', importData.parsedInstance]); + } + if (importData.videoId) { + tags.push(['peertube-video-id', importData.videoId]); + } + if (importData.url) { + tags.push(['peertube-watch', importData.url]); + } + const account = metadata.account || metadata.owner || metadata.user || null; + let accountCreator = ''; + if (account) { + const usernamePart = account.username || ''; + const hostPart = account.host ? `@${account.host}` : ''; + if (usernamePart || hostPart) { + accountCreator = `${usernamePart}${hostPart}`; + } + } + const creator = importData.author || accountCreator; + if (creator) { + tags.push(['peertube-author', creator]); + } + if (metadata.account?.nip05) { + tags.push(['peertube-nip05', metadata.account.nip05]); + } + const normalizedPubkey = normalizeNostrPubkey(importData.nostr); + if (normalizedPubkey) { + tags.push(['p', normalizedPubkey]); + tags.push(['peertube-nostr', normalizedPubkey]); + } else if (importData.nostr) { + tags.push(['peertube-nostr-raw', importData.nostr]); + } + + if (importData.allowTorrent) { + tags.push(['peertube-allow-webtorrent', 'true']); + if (importData.magnet) { + tags.push(['peertube-magnet', importData.magnet]); + } + } + + return tags; +} + +function normalizeNostrPubkey(value) { + if (!value) return null; + const trimmed = value.trim(); + + try { + const decoded = window.NostrTools.nip19.decode(trimmed); + if (decoded?.type === 'npub') { + return decoded.data; + } + if (decoded?.type === 'nprofile' && decoded?.data?.pubkey) { + return decoded.data.pubkey; + } + } catch (error) { + // ignore + } + + if (/^[0-9A-Fa-f]{64}$/.test(trimmed)) { + return trimmed.toLowerCase(); + } + + return null; +} + +async function derivePeertubeHash(importData, metadata, primaryHash) { + if (primaryHash) return primaryHash; + const candidate = metadata.hash || metadata.sha256 || metadata.videoHash || ''; + if (candidate) return candidate; + + const fallbackInput = importData.url || metadata.watchUrl || metadata.streamUrl || metadata.snapshotUrl || ''; + if (!fallbackInput) return ''; + + try { + const encoder = new TextEncoder(); + const data = encoder.encode(fallbackInput); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(hashBuffer)).map(byte => byte.toString(16).padStart(2, '0')).join(''); + } catch (error) { + console.error('Failed to derive Peertube hash:', error); + return ''; + } +} + +document.addEventListener('DOMContentLoaded', () => { + const peertubeForm = document.getElementById('peertubeImportForm'); + if (peertubeForm) { + peertubeForm.addEventListener('submit', handlePeertubeImport); + } +}); From e6f863af2a24f22e8eeebba648adb806fbf0515a Mon Sep 17 00:00:00 2001 From: imattau Date: Sun, 4 Jan 2026 09:44:15 +1100 Subject: [PATCH 07/14] Improve Peertube stream candidates --- app.js | 368 ++++++++++++++++++++++++++++++++++++++++++++-------- peertube.js | 67 ++++++++-- styles.css | 53 ++++++++ 3 files changed, 423 insertions(+), 65 deletions(-) diff --git a/app.js b/app.js index 74b1091..22c7d93 100644 --- a/app.js +++ b/app.js @@ -30567,8 +30567,8 @@ async function playVideo(eventId, skipNSFWCheck = false, skipRatioedCheck = fals ` : ''; const webTorrentConsentHtml = canStartWebTorrent ? ` -