Files
duplicati_preview/public/file.js
T

1318 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
assertBrowserFeature,
createFeedbackController,
escapeHtml,
fetchJson,
formatBytes,
linkToSource,
optionalQueryParam
} from './modules/common.js';
import { bindLogoutButton, loadAuthState } from './modules/auth-client.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 authStatusElement = document.querySelector('#authStatus');
const logoutButton = document.querySelector('#logoutButton');
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 = `<div class="summary-stack"><div class="summary-row"><strong>${escapeHtml(message)}</strong></div></div>`;
}
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 = `
<div class="empty-state-card">
<p>这个页面缺少必要的地址参数。</p>
<p>缺少:${escapeHtml(missing.join(', '))}</p>
<p>请从数据源工作台里的文件列表进入,或检查当前地址是否包含 <code>sourceId</code> 和 <code>id</code>。</p>
</div>
`;
setSummaryCard(sessionStatusElement, '尚未注册媒体会话。');
setSummaryCard(cacheStatusElement, '本地缓存不可用。');
setSummaryCard(downloadStatusElement, '下载不可用。');
setSummaryCard(thumbnailStatusElement, '预览图不可用。');
setSummaryCard(previewThumbnailHintElement, '当前无法管理预览图。');
thumbnailPreviewElement.innerHTML = '<div class="thumbnail-placeholder">缺少地址参数</div>';
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 = `
<div class="summary-stack">
<div class="summary-row">
<span>任务默认 URL</span>
<strong>${escapeHtml(sourceWebdav?.effectiveWebdavBaseUrl ?? sourceWebdav?.derivedWebdavBaseUrl ?? '未推导到')}</strong>
</div>
<div class="summary-row">
<span>默认 URL 来源</span>
<strong>${escapeHtml(sourceWebdav?.effectiveWebdavBaseUrlSource ?? 'none')}</strong>
</div>
<div class="summary-row">
<span>浏览器全局默认值</span>
<strong>${state.browserGlobalSecrets ? 'available' : 'missing'}</strong>
</div>
<div class="summary-row">
<span>浏览器单源覆盖</span>
<strong>${state.browserSourceSecrets ? 'saved' : 'none'}</strong>
</div>
<div class="summary-row">
<span>后端解析后的直连凭据</span>
<strong>${state.resolvedBrowserSecrets ? 'available' : state.resolvedBrowserSecretsError ? 'missing' : 'pending'}</strong>
</div>
${
state.resolvedBrowserSecretsError
? `
<div class="summary-row">
<span>后端凭据状态</span>
<strong>${escapeHtml(state.resolvedBrowserSecretsError.message)}</strong>
</div>
`
: ''
}
</div>
`;
}
function renderWorkerStatus() {
if (!state.workerStatus) {
setSummaryCard(sessionStatusElement, '尚未注册媒体会话。');
return;
}
sessionStatusElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>当前阶段</span>
<strong>${escapeHtml(state.workerStatus.phase ?? 'idle')}</strong>
</div>
<div class="summary-row">
<span>当前卷</span>
<strong>${escapeHtml(state.workerStatus.currentVolume ?? '无')}</strong>
</div>
<div class="summary-row">
<span>已缓存 dblock</span>
<strong>${escapeHtml(String((state.workerStatus.cachedVolumes ?? []).length))}</strong>
</div>
<div class="summary-row">
<span>缓存列表</span>
<strong>${escapeHtml((state.workerStatus.cachedVolumes ?? []).join(', ') || '暂无缓存')}</strong>
</div>
<div class="summary-row">
<span>最近错误</span>
<strong>${escapeHtml(state.workerStatus.lastError ?? '无')}</strong>
</div>
</div>
`;
}
function renderPreviewThumbnailHint() {
if (!state.fileInfo) {
setSummaryCard(previewThumbnailHintElement, state.fileInfoError?.message ?? '当前文件还不能管理预览图。');
return;
}
const thumbnail = state.fileInfo.file.thumbnail;
const captureMessage = state.thumbnailCapture.lastMessage ?? '尚未生成预览图。';
previewThumbnailHintElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>当前缩略图</span>
<strong>${thumbnail?.available ? '已缓存到后端' : '暂无'}</strong>
</div>
<div class="summary-row">
<span>自动截帧状态</span>
<strong>${escapeHtml(captureMessage)}</strong>
</div>
<div class="summary-row">
<span>删除说明</span>
<strong>只删除后端缩略图缓存,不会删除浏览器本地视频缓存。</strong>
</div>
</div>
`;
}
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 = `
<div class="summary-stack">
<div class="summary-row">
<span>当前 source 缓存</span>
<strong>${escapeHtml(String(currentSource.volumeCount))} 个 dblock / ${formatBytes(currentSource.totalBytes)}</strong>
</div>
<div class="summary-row">
<span>全部浏览器缓存</span>
<strong>${escapeHtml(String(global.sourceCount))} 个 source${escapeHtml(String(global.volumeCount))} 个 dblock / ${formatBytes(global.totalBytes)}</strong>
</div>
</div>
`;
}
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 = `
<article class="source-card">
<div class="source-main">
<div>
<p class="source-name">${escapeHtml(file.name)}</p>
<p class="source-meta">${escapeHtml(file.path)}</p>
<p class="source-meta">sourceId: ${escapeHtml(state.sourceId)}</p>
</div>
<span class="status-pill ${state.source?.enhancement?.status === 'ready' ? 'status-ready' : 'status-idle'}">
${escapeHtml(state.source?.enhancement?.status ?? 'unknown')}
</span>
</div>
<div class="stats-grid">
<div class="stat">
<span>MIME</span>
<strong>${escapeHtml(file.mime ?? 'unknown')}</strong>
</div>
<div class="stat">
<span>文件大小</span>
<strong>${formatBytes(file.size)}</strong>
</div>
<div class="stat">
<span>恢复片段</span>
<strong>${escapeHtml(String(state.fileInfo.restorePlan.segmentCount))}</strong>
</div>
<div class="stat">
<span>dblock 数量</span>
<strong>${escapeHtml(String(volumeCount))}</strong>
</div>
<div class="stat">
<span>块大小</span>
<strong>${formatBytes(state.fileInfo.restorePlan.blockSize)}</strong>
</div>
<div class="stat">
<span>范围播放</span>
<strong>${state.fileInfo.hints.rangeReady ? '可用' : '不可用'}</strong>
</div>
<div class="stat">
<span>后端预览图</span>
<strong>${state.fileInfo.file.thumbnail?.available ? '已缓存' : '暂无'}</strong>
</div>
</div>
<div class="summary-chip">${isVideo ? '这是一个视频文件,可以启用前端 Range 播放。' : '这不是视频文件,播放器不会启用,但仍然可以直接下载。'}</div>
</article>
`;
videoHintElement.textContent = isVideo
? '点击“启动视频预览”后,浏览器会注册本地媒体会话,并通过 Service Worker 伪造 Range 请求。'
: '这个文件不是视频,视频预览区不会启用,但仍然可以直接下载。';
updateActionStates();
return;
}
const errorMessage = state.fileInfoError?.message ?? '尚未获得文件恢复信息。';
fileTitleElement.textContent = '文件操作未就绪';
fileSummaryElement.innerHTML = `
<div class="empty-state-card">
<p>这个文件当前还不能进入视频预览或下载流程。</p>
<p>${escapeHtml(errorMessage)}</p>
</div>
`;
videoHintElement.textContent = '请先回到数据源工作台完成增强,再回到文件页。';
updateActionStates();
}
function renderThumbnailPanel() {
if (!state.fileInfo) {
setSummaryCard(thumbnailStatusElement, state.fileInfoError?.message ?? '当前文件还不能读取预览图状态。');
thumbnailPreviewElement.innerHTML = '<div class="thumbnail-placeholder">暂无预览图</div>';
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 = `
<div class="summary-stack">
<div class="summary-row">
<span>当前状态</span>
<strong>${escapeHtml(statusText)}</strong>
</div>
<div class="summary-row">
<span>自动生成状态</span>
<strong>${escapeHtml(captureMessage)}</strong>
</div>
<div class="summary-row">
<span>缩略图 ID</span>
<strong>${escapeHtml(thumbnail?.thumbnailId ?? '无')}</strong>
</div>
</div>
`;
if (thumbnail?.available) {
thumbnailPreviewElement.innerHTML = `
<img
class="thumbnail-preview-image"
src="${escapeHtml(thumbnail.thumbnailUrl)}"
alt="${escapeHtml(state.fileInfo.file.name)} 的预览图"
>
`;
} else {
thumbnailPreviewElement.innerHTML = `
<div class="thumbnail-placeholder">${capture.inFlight ? '正在生成预览图...' : '暂无预览图'}</div>
`;
}
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 = `
<div class="summary-stack">
<div class="summary-row">
<span>当前卷</span>
<strong>${escapeHtml(volume?.name ?? segment.volumeRef)}</strong>
</div>
<div class="summary-row">
<span>已写入</span>
<strong>${formatBytes(writtenBytes)} / ${formatBytes(state.fileInfo.file.size)}</strong>
</div>
</div>
`;
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 = `
<div class="summary-stack">
<div class="summary-row">
<span>下载状态</span>
<strong>已完成</strong>
</div>
<div class="summary-row">
<span>写入总量</span>
<strong>${formatBytes(writtenBytes)}</strong>
</div>
</div>
`;
feedback.set('success', '文件已经直接恢复并写入本地。');
} catch (error) {
downloadStatusElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>下载状态</span>
<strong>${escapeHtml(error.message)}</strong>
</div>
</div>
`;
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 });
const authentication = await loadAuthState();
authStatusElement.textContent = authentication.authenticated
? `已登录:${authentication.username}`
: '未登录';
bindLogoutButton(logoutButton, authStatusElement);
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 = `
<div class="empty-state-card">
<p>无法进入文件页。</p>
</div>
`;
});