import { assertBrowserFeature, createFeedbackController, escapeHtml, fetchJson, formatBytes, linkToSource, optionalQueryParam } from './modules/common.js'; import { loadClientSecrets, loadGlobalClientSecrets, removeClientSecrets, saveClientSecrets, saveGlobalClientSecrets } from './modules/clientSecrets.js'; import { buildVolumeMap, clearAllCachedVolumes, clearCachedVolumesForSource, getCachedVolumeStats, getGlobalCacheStats, isVideoMime, restoreSegmentBytes, supportsFileDownload, supportsOpfs, supportsVideoPreview } from './modules/media-core.js'; import { analyzeFrameLuma, buildThumbnailCandidateTimes } from './modules/thumbnail-utils.js'; const STATUS_CHANNEL_NAME = 'duplicati-media-status'; const FILE_VIEWS = new Set(['overview', 'preview', 'download', 'cache', 'thumbnail']); const state = { sourceId: optionalQueryParam('sourceId', ''), fileId: optionalQueryParam('id', ''), source: null, fileInfo: null, fileInfoError: null, cacheStats: { currentSource: null, global: null }, resolvedBrowserSecrets: null, resolvedBrowserSecretsError: null, currentSessionId: null, workerStatus: null, workerChannel: null, browserGlobalSecrets: null, browserSourceSecrets: null, activePreviewSecrets: null, currentView: 'overview', pageLocked: false, thumbnailCapture: { inFlight: false, attempted: false, suppressed: false, sessionId: null, probeSessionId: null, lastMessage: '尚未生成预览图。' } }; const feedback = createFeedbackController(document.querySelector('#feedback')); const sourceCrumb = document.querySelector('#sourceCrumb'); const fileTitleElement = document.querySelector('#fileTitle'); const fileSummaryElement = document.querySelector('#fileSummary'); const sessionStatusElement = document.querySelector('#sessionStatus'); const cacheStatusElement = document.querySelector('#cacheStatus'); const downloadStatusElement = document.querySelector('#downloadStatus'); const previewPlayer = document.querySelector('#previewPlayer'); const videoHintElement = document.querySelector('#videoHint'); const startPreviewButton = document.querySelector('#startPreviewButton'); const deleteThumbnailFromPreviewButton = document.querySelector('#deleteThumbnailFromPreviewButton'); const downloadButton = document.querySelector('#downloadButton'); const clearSourceCacheButton = document.querySelector('#clearSourceCacheButton'); const clearAllCacheButton = document.querySelector('#clearAllCacheButton'); const saveClientSecretsButton = document.querySelector('#saveClientSecretsButton'); const saveClientGlobalsButton = document.querySelector('#saveClientGlobalsButton'); const deleteThumbnailButton = document.querySelector('#deleteThumbnailButton'); const clientSecretForm = document.querySelector('#clientSecretForm'); const clientSecretHintElement = document.querySelector('#clientSecretHint'); const previewThumbnailHintElement = document.querySelector('#previewThumbnailHint'); const thumbnailStatusElement = document.querySelector('#thumbnailStatus'); const thumbnailPreviewElement = document.querySelector('#thumbnailPreview'); const thumbnailCaptureCanvas = document.querySelector('#thumbnailCaptureCanvas'); const viewPanels = [...document.querySelectorAll('[data-view-panel]')]; const viewLinks = [...document.querySelectorAll('[data-view-link]')]; sourceCrumb.href = state.sourceId ? `${linkToSource(state.sourceId)}#browse` : '/'; function normalizeView(value) { return FILE_VIEWS.has(value) ? value : 'overview'; } function setCurrentView(nextView, { syncHash = true, replace = false } = {}) { state.currentView = normalizeView(nextView); for (const panel of viewPanels) { panel.hidden = panel.dataset.viewPanel !== state.currentView; } for (const link of viewLinks) { const active = link.dataset.viewLink === state.currentView; link.classList.toggle('is-active', active); link.setAttribute('aria-current', active ? 'page' : 'false'); } if (!syncHash) { return; } const url = new URL(window.location.href); url.hash = `#${state.currentView}`; if (replace) { window.history.replaceState(null, '', url); } else { window.history.pushState(null, '', url); } } function setSummaryCard(element, message) { element.innerHTML = `
${escapeHtml(message)}
`; } function isPreviewAvailable() { return Boolean(state.fileInfo && isVideoMime(state.fileInfo.file.mime)); } function isFileRestorable() { return Boolean(state.fileInfo); } function updateActionStates() { const locked = state.pageLocked; startPreviewButton.disabled = locked || !isPreviewAvailable(); downloadButton.disabled = locked || !isFileRestorable(); clearSourceCacheButton.disabled = locked || !supportsOpfs() || !state.sourceId; clearAllCacheButton.disabled = locked || !supportsOpfs(); saveClientSecretsButton.disabled = locked; saveClientGlobalsButton.disabled = locked; deleteThumbnailButton.disabled = locked || !Boolean(state.fileInfo?.file?.thumbnail?.available); deleteThumbnailFromPreviewButton.disabled = locked || !Boolean(state.fileInfo?.file?.thumbnail?.available); for (const element of clientSecretForm.querySelectorAll('input, select')) { element.disabled = locked; } } function setControlsDisabled(disabled) { state.pageLocked = disabled; updateActionStates(); } function renderMissingRouteContext() { const missing = []; if (!state.sourceId) { missing.push('sourceId'); } if (!state.fileId) { missing.push('id'); } setControlsDisabled(true); fileTitleElement.textContent = '文件页未就绪'; fileSummaryElement.innerHTML = `

这个页面缺少必要的地址参数。

缺少:${escapeHtml(missing.join(', '))}

请从数据源工作台里的文件列表进入,或检查当前地址是否包含 sourceIdid

`; setSummaryCard(sessionStatusElement, '尚未注册媒体会话。'); setSummaryCard(cacheStatusElement, '本地缓存不可用。'); setSummaryCard(downloadStatusElement, '下载不可用。'); setSummaryCard(thumbnailStatusElement, '预览图不可用。'); setSummaryCard(previewThumbnailHintElement, '当前无法管理预览图。'); thumbnailPreviewElement.innerHTML = '
缺少地址参数
'; setCurrentView('overview', { replace: true }); } function readClientSecretsForm() { return { webdavBaseUrl: document.querySelector('#clientWebdavBaseUrl')?.value?.trim() ?? '', authMode: document.querySelector('#clientAuthMode')?.value ?? 'basic', username: document.querySelector('#clientUsername')?.value?.trim() ?? '', password: document.querySelector('#clientPassword')?.value ?? '', passphrase: document.querySelector('#clientPassphrase')?.value ?? '', rememberOnDevice: Boolean(document.querySelector('#rememberOnDevice')?.checked) }; } function writeClientSecretsForm(secrets) { document.querySelector('#clientWebdavBaseUrl').value = secrets?.webdavBaseUrl ?? ''; document.querySelector('#clientAuthMode').value = secrets?.authMode ?? 'basic'; document.querySelector('#clientUsername').value = secrets?.username ?? ''; document.querySelector('#clientPassword').value = secrets?.password ?? ''; document.querySelector('#clientPassphrase').value = secrets?.passphrase ?? ''; document.querySelector('#rememberOnDevice').checked = Boolean(secrets?.rememberOnDevice); } function mergeClientSecrets({ sourceSecrets, globalSecrets, resolvedSecrets, sourceWebdav }) { return { webdavBaseUrl: sourceSecrets?.webdavBaseUrl ?? resolvedSecrets?.webdavBaseUrl ?? sourceWebdav?.effectiveWebdavBaseUrl ?? sourceWebdav?.derivedWebdavBaseUrl ?? globalSecrets?.webdavBaseUrl ?? '', authMode: sourceSecrets?.authMode ?? globalSecrets?.authMode ?? resolvedSecrets?.authMode ?? 'basic', username: sourceSecrets?.username ?? globalSecrets?.username ?? resolvedSecrets?.username ?? '', password: sourceSecrets?.password ?? globalSecrets?.password ?? resolvedSecrets?.password ?? '', passphrase: sourceSecrets?.passphrase ?? globalSecrets?.passphrase ?? resolvedSecrets?.passphrase ?? '', rememberOnDevice: Boolean(sourceSecrets) }; } function requireClientSecrets() { const secrets = readClientSecretsForm(); if (!secrets.webdavBaseUrl) { throw new Error('CLIENT_SECRETS_MISSING: 请输入浏览器直连使用的 WebDAV Base URL。'); } if (!secrets.passphrase) { throw new Error('CLIENT_SECRETS_MISSING: 请输入浏览器直连使用的备份口令。'); } return secrets; } async function persistClientSecretsPreference(secrets) { if (secrets.rememberOnDevice) { await saveClientSecrets(state.sourceId, secrets); } else { await removeClientSecrets(state.sourceId); } } function renderClientSecretHint() { const sourceWebdav = state.source?.webdav; clientSecretHintElement.innerHTML = `
任务默认 URL ${escapeHtml(sourceWebdav?.effectiveWebdavBaseUrl ?? sourceWebdav?.derivedWebdavBaseUrl ?? '未推导到')}
默认 URL 来源 ${escapeHtml(sourceWebdav?.effectiveWebdavBaseUrlSource ?? 'none')}
浏览器全局默认值 ${state.browserGlobalSecrets ? 'available' : 'missing'}
浏览器单源覆盖 ${state.browserSourceSecrets ? 'saved' : 'none'}
后端解析后的直连凭据 ${state.resolvedBrowserSecrets ? 'available' : state.resolvedBrowserSecretsError ? 'missing' : 'pending'}
${ state.resolvedBrowserSecretsError ? `
后端凭据状态 ${escapeHtml(state.resolvedBrowserSecretsError.message)}
` : '' }
`; } function renderWorkerStatus() { if (!state.workerStatus) { setSummaryCard(sessionStatusElement, '尚未注册媒体会话。'); return; } sessionStatusElement.innerHTML = `
当前阶段 ${escapeHtml(state.workerStatus.phase ?? 'idle')}
当前卷 ${escapeHtml(state.workerStatus.currentVolume ?? '无')}
已缓存 dblock ${escapeHtml(String((state.workerStatus.cachedVolumes ?? []).length))}
缓存列表 ${escapeHtml((state.workerStatus.cachedVolumes ?? []).join(', ') || '暂无缓存')}
最近错误 ${escapeHtml(state.workerStatus.lastError ?? '无')}
`; } function renderPreviewThumbnailHint() { if (!state.fileInfo) { setSummaryCard(previewThumbnailHintElement, state.fileInfoError?.message ?? '当前文件还不能管理预览图。'); return; } const thumbnail = state.fileInfo.file.thumbnail; const captureMessage = state.thumbnailCapture.lastMessage ?? '尚未生成预览图。'; previewThumbnailHintElement.innerHTML = `
当前缩略图 ${thumbnail?.available ? '已缓存到后端' : '暂无'}
自动截帧状态 ${escapeHtml(captureMessage)}
删除说明 只删除后端缩略图缓存,不会删除浏览器本地视频缓存。
`; } function renderCacheStatus() { if (!supportsOpfs()) { setSummaryCard(cacheStatusElement, '当前浏览器不支持 OPFS,本地缓存功能不可用。'); updateActionStates(); return; } const currentSource = state.cacheStats.currentSource ?? { volumeCount: 0, totalBytes: 0 }; const global = state.cacheStats.global ?? { sourceCount: 0, volumeCount: 0, totalBytes: 0 }; cacheStatusElement.innerHTML = `
当前 source 缓存 ${escapeHtml(String(currentSource.volumeCount))} 个 dblock / ${formatBytes(currentSource.totalBytes)}
全部浏览器缓存 ${escapeHtml(String(global.sourceCount))} 个 source,${escapeHtml(String(global.volumeCount))} 个 dblock / ${formatBytes(global.totalBytes)}
`; } function renderFileSummary() { if (state.fileInfo) { const file = state.fileInfo.file; const volumeCount = state.fileInfo.volumes.length; const isVideo = isVideoMime(file.mime); fileTitleElement.textContent = file.name; fileSummaryElement.innerHTML = `

${escapeHtml(file.name)}

${escapeHtml(file.path)}

sourceId: ${escapeHtml(state.sourceId)}

${escapeHtml(state.source?.enhancement?.status ?? 'unknown')}
MIME ${escapeHtml(file.mime ?? 'unknown')}
文件大小 ${formatBytes(file.size)}
恢复片段 ${escapeHtml(String(state.fileInfo.restorePlan.segmentCount))}
dblock 数量 ${escapeHtml(String(volumeCount))}
块大小 ${formatBytes(state.fileInfo.restorePlan.blockSize)}
范围播放 ${state.fileInfo.hints.rangeReady ? '可用' : '不可用'}
后端预览图 ${state.fileInfo.file.thumbnail?.available ? '已缓存' : '暂无'}
${isVideo ? '这是一个视频文件,可以启用前端 Range 播放。' : '这不是视频文件,播放器不会启用,但仍然可以直接下载。'}
`; videoHintElement.textContent = isVideo ? '点击“启动视频预览”后,浏览器会注册本地媒体会话,并通过 Service Worker 伪造 Range 请求。' : '这个文件不是视频,视频预览区不会启用,但仍然可以直接下载。'; updateActionStates(); return; } const errorMessage = state.fileInfoError?.message ?? '尚未获得文件恢复信息。'; fileTitleElement.textContent = '文件操作未就绪'; fileSummaryElement.innerHTML = `

这个文件当前还不能进入视频预览或下载流程。

${escapeHtml(errorMessage)}

`; videoHintElement.textContent = '请先回到数据源工作台完成增强,再回到文件页。'; updateActionStates(); } function renderThumbnailPanel() { if (!state.fileInfo) { setSummaryCard(thumbnailStatusElement, state.fileInfoError?.message ?? '当前文件还不能读取预览图状态。'); thumbnailPreviewElement.innerHTML = '
暂无预览图
'; renderPreviewThumbnailHint(); updateActionStates(); return; } const thumbnail = state.fileInfo.file.thumbnail; const capture = state.thumbnailCapture; const statusText = thumbnail?.available ? '已缓存到后端' : capture.inFlight ? '正在从当前视频首帧生成' : '暂无预览图'; const captureMessage = capture.lastMessage ?? '尚未生成预览图。'; thumbnailStatusElement.innerHTML = `
当前状态 ${escapeHtml(statusText)}
自动生成状态 ${escapeHtml(captureMessage)}
缩略图 ID ${escapeHtml(thumbnail?.thumbnailId ?? '无')}
`; if (thumbnail?.available) { thumbnailPreviewElement.innerHTML = ` ${escapeHtml(state.fileInfo.file.name)} 的预览图 `; } else { thumbnailPreviewElement.innerHTML = `
${capture.inFlight ? '正在生成预览图...' : '暂无预览图'}
`; } renderPreviewThumbnailHint(); updateActionStates(); } async function sendWorkerMessage(type, payload) { const registration = await navigator.serviceWorker.ready; const worker = navigator.serviceWorker.controller ?? registration.active ?? registration.waiting; if (!worker) { throw new Error('SERVICE_WORKER_UNAVAILABLE: Service Worker is not ready.'); } return new Promise((resolve, reject) => { const channel = new MessageChannel(); const timeoutId = setTimeout(() => { reject(new Error('SERVICE_WORKER_TIMEOUT: Service Worker did not reply in time.')); }, 10_000); channel.port1.onmessage = (event) => { clearTimeout(timeoutId); if (event.data?.ok) { resolve(event.data); } else { reject(new Error(event.data?.error ?? 'Service Worker request failed.')); } }; worker.postMessage( { type, ...payload }, [channel.port2] ); }); } async function ensureServiceWorkerReady() { assertBrowserFeature( navigator.serviceWorker, 'SERVICE_WORKER_UNSUPPORTED: 当前浏览器不支持 Service Worker。' ); await navigator.serviceWorker.register('/media-sw.js', { scope: '/', type: 'module' }); await navigator.serviceWorker.ready; if (!navigator.serviceWorker.controller) { await new Promise((resolve) => { navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true }); }); } } function ensureWorkerStatusChannel() { if (state.workerChannel || typeof BroadcastChannel !== 'function') { return; } state.workerChannel = new BroadcastChannel(STATUS_CHANNEL_NAME); state.workerChannel.addEventListener('message', (event) => { if (event.data?.type !== 'status' || event.data.sessionId !== state.currentSessionId) { return; } const previousCachedCount = state.workerStatus?.cachedVolumes?.length ?? 0; state.workerStatus = event.data.status; renderWorkerStatus(); const nextCachedCount = state.workerStatus?.cachedVolumes?.length ?? 0; if (nextCachedCount !== previousCachedCount) { void refreshCacheStats(); } }); } async function registerPreviewSession(secrets) { await ensureServiceWorkerReady(); ensureWorkerStatusChannel(); if (state.currentSessionId) { try { await sendWorkerMessage('unregister-session', { sessionId: state.currentSessionId }); } catch (error) { // ignore stale sessions during re-registration } } const sessionId = crypto.randomUUID(); const response = await sendWorkerMessage('register-session', { sessionId, sourceId: state.sourceId, fileInfo: state.fileInfo, secrets }); state.currentSessionId = sessionId; state.workerStatus = response.status; renderWorkerStatus(); return sessionId; } function resetThumbnailCaptureState(sessionId = null) { state.thumbnailCapture = { inFlight: false, attempted: Boolean(state.fileInfo?.file?.thumbnail?.available), suppressed: false, sessionId, probeSessionId: null, lastMessage: state.fileInfo?.file?.thumbnail?.available ? '当前文件已存在后端预览图。' : '等待视频首帧解码后自动生成。' }; renderThumbnailPanel(); } async function unregisterWorkerSession(sessionId) { if (!sessionId) { return; } try { await ensureServiceWorkerReady(); await sendWorkerMessage('unregister-session', { sessionId }); } catch (error) { // ignore stale or already-closed sessions during local cleanup } } async function stopCurrentPreviewSession() { previewPlayer.pause(); previewPlayer.removeAttribute('src'); previewPlayer.load(); await unregisterWorkerSession(state.thumbnailCapture.probeSessionId); state.thumbnailCapture.probeSessionId = null; if (!state.currentSessionId) { state.workerStatus = null; renderWorkerStatus(); return; } await unregisterWorkerSession(state.currentSessionId); state.currentSessionId = null; state.activePreviewSecrets = null; state.workerStatus = null; renderWorkerStatus(); } async function saveClientSecretsLocally(event) { event.preventDefault(); feedback.set('info', '正在更新浏览器本地 source 覆盖...'); try { const secrets = requireClientSecrets(); await persistClientSecretsPreference(secrets); state.browserSourceSecrets = secrets.rememberOnDevice ? secrets : null; renderClientSecretHint(); feedback.set( secrets.rememberOnDevice ? 'success' : 'info', secrets.rememberOnDevice ? '当前 source 的浏览器本地覆盖已保存。' : '没有勾选记住,本次只保留在当前页面内存中。' ); } catch (error) { feedback.set('error', error.message); } } async function saveClientSecretsAsGlobal() { feedback.set('info', '正在保存浏览器全局默认值...'); try { const secrets = requireClientSecrets(); await saveGlobalClientSecrets(secrets); state.browserGlobalSecrets = secrets; renderClientSecretHint(); feedback.set('success', '浏览器全局默认值已保存。后续文件页会默认继承这些字段。'); } catch (error) { feedback.set('error', error.message); } } function waitForEventOnce(target, eventName, timeoutMs, readyCheck = null) { if (typeof readyCheck === 'function' && readyCheck()) { return Promise.resolve(); } return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { cleanup(); reject(new Error(`THUMBNAIL_EVENT_TIMEOUT: Timed out waiting for ${eventName}.`)); }, timeoutMs); function cleanup() { clearTimeout(timeoutId); target.removeEventListener(eventName, handleSuccess); target.removeEventListener('error', handleError); } function handleSuccess() { cleanup(); resolve(); } function handleError() { cleanup(); reject(new Error(`THUMBNAIL_VIDEO_ERROR: Hidden thumbnail probe video failed during ${eventName}.`)); } target.addEventListener(eventName, handleSuccess, { once: true }); target.addEventListener('error', handleError, { once: true }); }); } async function waitForStableVideoFrame(videoElement) { if (typeof videoElement.requestVideoFrameCallback === 'function') { await new Promise((resolve) => { videoElement.requestVideoFrameCallback(() => resolve()); }); return; } await waitForEventOnce( videoElement, 'loadeddata', 5_000, () => videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA ); await new Promise((resolve) => setTimeout(resolve, 80)); } async function seekHiddenVideoTo(videoElement, seconds) { const targetTime = Number(seconds); if (!Number.isFinite(targetTime) || targetTime < 0) { return; } if (Math.abs(videoElement.currentTime - targetTime) > 0.05) { const seekPromise = waitForEventOnce(videoElement, 'seeked', 12_000); videoElement.currentTime = targetTime; await seekPromise; } else if (videoElement.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { await waitForEventOnce( videoElement, 'loadeddata', 12_000, () => videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA ); } await waitForStableVideoFrame(videoElement); } async function createThumbnailBlobFromVideo(videoElement) { const maxWidth = 480; const sourceWidth = Math.max(videoElement.videoWidth || 0, 1); const sourceHeight = Math.max(videoElement.videoHeight || 0, 1); const scale = Math.min(1, maxWidth / sourceWidth); const targetWidth = Math.max(1, Math.round(sourceWidth * scale)); const targetHeight = Math.max(1, Math.round(sourceHeight * scale)); thumbnailCaptureCanvas.width = targetWidth; thumbnailCaptureCanvas.height = targetHeight; const context = thumbnailCaptureCanvas.getContext('2d', { alpha: false }); if (!context) { throw new Error('THUMBNAIL_CANVAS_UNAVAILABLE: 无法创建缩略图绘制上下文。'); } context.drawImage(videoElement, 0, 0, targetWidth, targetHeight); const frameAnalysis = analyzeFrameLuma( context.getImageData(0, 0, targetWidth, targetHeight).data ); return new Promise((resolve, reject) => { thumbnailCaptureCanvas.toBlob( (blob) => { if (!blob) { reject(new Error('THUMBNAIL_CAPTURE_FAILED: 浏览器没有生成有效的缩略图 Blob。')); return; } resolve({ blob, frameAnalysis }); }, 'image/webp', 0.82 ); }); } async function createThumbnailProbeVideo(sessionId) { const probeVideo = document.createElement('video'); probeVideo.muted = true; probeVideo.playsInline = true; probeVideo.preload = 'auto'; probeVideo.controls = false; probeVideo.style.position = 'fixed'; probeVideo.style.opacity = '0'; probeVideo.style.pointerEvents = 'none'; probeVideo.style.width = '1px'; probeVideo.style.height = '1px'; probeVideo.style.left = '-9999px'; probeVideo.style.top = '-9999px'; probeVideo.src = `/__media__/session/${encodeURIComponent(sessionId)}`; document.body.append(probeVideo); probeVideo.load(); await waitForEventOnce( probeVideo, 'loadedmetadata', 12_000, () => probeVideo.readyState >= HTMLMediaElement.HAVE_METADATA ); return probeVideo; } function cleanupThumbnailProbeVideo(videoElement) { if (!videoElement) { return; } videoElement.pause(); videoElement.removeAttribute('src'); videoElement.load(); videoElement.remove(); } async function maybeCapturePreviewThumbnail() { if (!state.fileInfo || !state.currentSessionId || !isVideoMime(state.fileInfo.file.mime)) { return; } if (state.fileInfo.file.thumbnail?.available) { return; } if (state.thumbnailCapture.inFlight || state.thumbnailCapture.attempted || state.thumbnailCapture.suppressed) { return; } if (previewPlayer.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || previewPlayer.videoWidth === 0) { return; } state.thumbnailCapture.inFlight = true; state.thumbnailCapture.attempted = true; state.thumbnailCapture.lastMessage = '正在寻找有效画面(准备探测候选时间点)...'; renderThumbnailPanel(); const activeSessionId = state.currentSessionId; const previewSecrets = state.activePreviewSecrets ?? readClientSecretsForm(); const probeSessionId = crypto.randomUUID(); state.thumbnailCapture.probeSessionId = probeSessionId; let probeVideo = null; try { await sendWorkerMessage('register-session', { sessionId: probeSessionId, sourceId: state.sourceId, fileInfo: state.fileInfo, secrets: previewSecrets }); probeVideo = await createThumbnailProbeVideo(probeSessionId); const candidates = buildThumbnailCandidateTimes(probeVideo.duration); let selectedBlob = null; let selectedMessage = ''; let fallbackBlob = null; for (let index = 0; index < candidates.length; index += 1) { if (state.currentSessionId !== activeSessionId || state.thumbnailCapture.sessionId !== activeSessionId) { state.thumbnailCapture.lastMessage = '当前会话已切换,跳过旧会话的预览图上传。'; return; } const candidate = candidates[index]; state.thumbnailCapture.lastMessage = `正在寻找有效画面(第 ${index + 1}/${candidates.length} 个候选时间点,${candidate.toFixed(2)}s)`; renderThumbnailPanel(); await seekHiddenVideoTo(probeVideo, candidate); const { blob, frameAnalysis } = await createThumbnailBlobFromVideo(probeVideo); fallbackBlob = blob; if (!frameAnalysis.isBlackFrame) { selectedBlob = blob; selectedMessage = `已选中第 ${index + 1}/${candidates.length} 个候选时间点(${candidate.toFixed(2)}s)。`; break; } state.thumbnailCapture.lastMessage = `已跳过黑帧(第 ${index + 1}/${candidates.length} 个候选时间点,${candidate.toFixed(2)}s),继续向后寻找。`; renderThumbnailPanel(); } const blobToUpload = selectedBlob ?? fallbackBlob; if (!blobToUpload) { throw new Error('THUMBNAIL_CAPTURE_FAILED: 没有拿到可上传的预览图。'); } if (!selectedMessage) { selectedMessage = '前面的候选时间点都偏黑,已回退到最后一个可用画面。'; } if (state.currentSessionId !== activeSessionId || state.thumbnailCapture.sessionId !== activeSessionId) { state.thumbnailCapture.lastMessage = '当前会话已切换,跳过旧会话的预览图上传。'; return; } const formData = new FormData(); formData.set('fileId', state.fileId); formData.set('image', blobToUpload, 'preview.webp'); const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/thumbnails`, { method: 'POST', body: formData }); state.fileInfo.file.thumbnail = payload.thumbnail; state.thumbnailCapture.lastMessage = `${selectedMessage} 预览图已缓存到后端。`; renderFileSummary(); renderThumbnailPanel(); } catch (error) { state.thumbnailCapture.lastMessage = error.message; renderThumbnailPanel(); } finally { cleanupThumbnailProbeVideo(probeVideo); await unregisterWorkerSession(probeSessionId); if (state.thumbnailCapture.probeSessionId === probeSessionId) { state.thumbnailCapture.probeSessionId = null; } state.thumbnailCapture.inFlight = false; renderThumbnailPanel(); } } async function startVideoPreview() { feedback.set('info', '正在注册浏览器端媒体会话...'); try { assertBrowserFeature( supportsVideoPreview(), 'BROWSER_PREVIEW_UNSUPPORTED: 当前浏览器不支持 OPFS、Service Worker 或 Range 预览所需能力。' ); if (!state.fileInfo) { throw state.fileInfoError ?? new Error('FILE_INFO_UNAVAILABLE: 当前文件还不能恢复。'); } if (!isVideoMime(state.fileInfo.file.mime)) { throw new Error('VIDEO_MIME_UNSUPPORTED: 当前文件不是视频,无法启用播放器。'); } const secrets = requireClientSecrets(); await persistClientSecretsPreference(secrets); state.activePreviewSecrets = { ...secrets }; const sessionId = await registerPreviewSession(secrets); resetThumbnailCaptureState(sessionId); previewPlayer.src = `/__media__/session/${encodeURIComponent(sessionId)}`; previewPlayer.load(); setCurrentView('preview'); feedback.set('success', '视频预览会话已注册,现在可以开始播放并拖动进度。'); } catch (error) { feedback.set('error', error.message); } } async function downloadFileDirectly() { feedback.set('info', '正在准备浏览器直连下载...'); try { assertBrowserFeature( supportsFileDownload(), 'BROWSER_DOWNLOAD_UNSUPPORTED: 当前浏览器不支持 OPFS 或 showSaveFilePicker。' ); if (!state.fileInfo) { throw state.fileInfoError ?? new Error('FILE_INFO_UNAVAILABLE: 当前文件还不能恢复。'); } const secrets = requireClientSecrets(); await persistClientSecretsPreference(secrets); const extension = state.fileInfo.file.name.includes('.') ? `.${state.fileInfo.file.name.split('.').pop()}` : '.bin'; const fileHandle = await showSaveFilePicker({ suggestedName: state.fileInfo.file.name, types: [ { description: 'Restored file', accept: { [state.fileInfo.file.mime || 'application/octet-stream']: [extension] } } ] }); const writable = await fileHandle.createWritable(); const volumeByRef = buildVolumeMap(state.fileInfo.volumes); let writtenBytes = 0; for (const segment of state.fileInfo.segments) { const volume = volumeByRef.get(segment.volumeRef); downloadStatusElement.innerHTML = `
当前卷 ${escapeHtml(volume?.name ?? segment.volumeRef)}
已写入 ${formatBytes(writtenBytes)} / ${formatBytes(state.fileInfo.file.size)}
`; const bytes = await restoreSegmentBytes({ sourceId: state.sourceId, volume, zip: segment.zip, secrets, onStatus: ({ phase, currentVolume }) => { state.workerStatus = { phase, currentVolume, cachedVolumes: state.workerStatus?.cachedVolumes ?? [], lastError: null }; renderWorkerStatus(); } }); await writable.write(bytes); writtenBytes += bytes.length; } await writable.close(); await refreshCacheStats(); downloadStatusElement.innerHTML = `
下载状态 已完成
写入总量 ${formatBytes(writtenBytes)}
`; feedback.set('success', '文件已经直接恢复并写入本地。'); } catch (error) { downloadStatusElement.innerHTML = `
下载状态 ${escapeHtml(error.message)}
`; feedback.set('error', error.message); } } async function refreshCacheStats() { if (!supportsOpfs()) { renderCacheStatus(); return; } const [currentSource, global] = await Promise.all([ state.sourceId ? getCachedVolumeStats(state.sourceId) : Promise.resolve({ volumeCount: 0, totalBytes: 0 }), getGlobalCacheStats() ]); state.cacheStats = { currentSource, global }; renderCacheStatus(); } async function clearCurrentSourceCache() { feedback.set('info', '正在清理当前 source 的浏览器本地缓存...'); try { assertBrowserFeature(supportsOpfs(), 'CACHE_UNSUPPORTED: 当前浏览器不支持 OPFS,本地缓存不可清理。'); await stopCurrentPreviewSession(); const result = await clearCachedVolumesForSource(state.sourceId); await refreshCacheStats(); feedback.set( 'success', `当前 source 缓存已清理,删除了 ${result.removedVolumes} 个 dblock,共 ${formatBytes(result.removedBytes)}。` ); } catch (error) { feedback.set('error', error.message); } } async function clearAllBrowserCache() { const confirmed = window.confirm( '确定要清理当前浏览器保存的全部视频和下载缓存吗?\n\n这不会删除后端数据库、增强索引或 Duplicati-server.sqlite,只会删除浏览器 OPFS 里的本地缓存。' ); if (!confirmed) { return; } feedback.set('info', '正在清理全部浏览器本地缓存...'); try { assertBrowserFeature(supportsOpfs(), 'CACHE_UNSUPPORTED: 当前浏览器不支持 OPFS,本地缓存不可清理。'); await stopCurrentPreviewSession(); const result = await clearAllCachedVolumes(); await refreshCacheStats(); feedback.set( 'success', `全部浏览器缓存已清理,删除了 ${result.removedSources} 个 source、${result.removedVolumes} 个 dblock,共 ${formatBytes(result.removedBytes)}。` ); } catch (error) { feedback.set('error', error.message); } } async function deleteCurrentThumbnail() { const thumbnailId = state.fileInfo?.file?.thumbnail?.thumbnailId; if (!thumbnailId) { return; } const confirmed = window.confirm( '确定要删除当前文件的预览图吗?\n\n这不会影响视频播放、下载或 Duplicati-server.sqlite,只会删除后端保存的这张缩略图。' ); if (!confirmed) { return; } feedback.set('info', '正在删除当前文件的预览图...'); try { await fetchJson( `/api/sources/${encodeURIComponent(state.sourceId)}/thumbnails/${encodeURIComponent(thumbnailId)}`, { method: 'DELETE' } ); state.fileInfo.file.thumbnail = { available: false, thumbnailId: null, thumbnailUrl: null }; state.thumbnailCapture.suppressed = true; state.thumbnailCapture.attempted = true; state.thumbnailCapture.lastMessage = '当前预览图已删除。下次启动新预览会话时会重新生成。'; renderFileSummary(); renderThumbnailPanel(); feedback.set('success', '当前文件的预览图已删除。'); } catch (error) { feedback.set('error', error.message); } } async function loadSource() { const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}`); state.source = payload.source; renderClientSecretHint(); } async function loadFileInfo() { try { const payload = await fetchJson( `/api/file-info?sourceId=${encodeURIComponent(state.sourceId)}&id=${encodeURIComponent(state.fileId)}` ); state.fileInfo = payload; state.fileInfoError = null; if (!state.fileInfo.file.thumbnail) { state.fileInfo.file.thumbnail = { available: false, thumbnailId: null, thumbnailUrl: null }; } } catch (error) { state.fileInfo = null; state.fileInfoError = error; } } async function loadResolvedBrowserSecrets() { try { const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/browser-secrets`); state.resolvedBrowserSecrets = payload.secrets ?? null; state.resolvedBrowserSecretsError = null; } catch (error) { state.resolvedBrowserSecrets = null; state.resolvedBrowserSecretsError = error; } } async function preloadClientSecrets() { const [globalSecrets, sourceSecrets] = await Promise.all([ loadGlobalClientSecrets(), loadClientSecrets(state.sourceId) ]); state.browserGlobalSecrets = globalSecrets; state.browserSourceSecrets = sourceSecrets; const merged = mergeClientSecrets({ sourceSecrets, globalSecrets, resolvedSecrets: state.resolvedBrowserSecrets, sourceWebdav: state.source?.webdav }); writeClientSecretsForm(merged); renderClientSecretHint(); } clientSecretForm.addEventListener('submit', (event) => { void saveClientSecretsLocally(event); }); saveClientGlobalsButton.addEventListener('click', () => { void saveClientSecretsAsGlobal(); }); clearSourceCacheButton.addEventListener('click', () => { void clearCurrentSourceCache(); }); clearAllCacheButton.addEventListener('click', () => { void clearAllBrowserCache(); }); startPreviewButton.addEventListener('click', () => { void startVideoPreview(); }); deleteThumbnailFromPreviewButton.addEventListener('click', () => { void deleteCurrentThumbnail(); }); downloadButton.addEventListener('click', () => { void downloadFileDirectly(); }); deleteThumbnailButton.addEventListener('click', () => { void deleteCurrentThumbnail(); }); previewPlayer.addEventListener('loadeddata', () => { void maybeCapturePreviewThumbnail(); }); for (const link of viewLinks) { link.addEventListener('click', (event) => { event.preventDefault(); setCurrentView(normalizeView(link.dataset.viewLink ?? 'overview')); }); } window.addEventListener('hashchange', () => { setCurrentView(normalizeView(window.location.hash.slice(1)), { syncHash: false }); }); window.addEventListener('beforeunload', () => { if (!navigator.serviceWorker?.controller) { return; } if (state.currentSessionId) { navigator.serviceWorker.controller.postMessage({ type: 'unregister-session', sessionId: state.currentSessionId }); } if (state.thumbnailCapture.probeSessionId) { navigator.serviceWorker.controller.postMessage({ type: 'unregister-session', sessionId: state.thumbnailCapture.probeSessionId }); } }); async function bootstrap() { const initialView = normalizeView(window.location.hash.slice(1) || 'overview'); setCurrentView(initialView, { replace: true }); if (!state.sourceId || !state.fileId) { renderMissingRouteContext(); return; } setControlsDisabled(false); setSummaryCard(sessionStatusElement, '尚未注册媒体会话。'); setSummaryCard(cacheStatusElement, '正在统计浏览器本地缓存...'); setSummaryCard(downloadStatusElement, '尚未开始下载。'); setSummaryCard(thumbnailStatusElement, '正在读取预览图状态...'); await Promise.all([loadSource(), loadFileInfo(), loadResolvedBrowserSecrets()]); await preloadClientSecrets(); await refreshCacheStats(); resetThumbnailCaptureState(null); renderFileSummary(); renderWorkerStatus(); renderThumbnailPanel(); updateActionStates(); } void bootstrap().catch((error) => { feedback.set('error', error.message); fileSummaryElement.innerHTML = `

无法进入文件页。

`; });