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 = `
`;
}
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(', '))}
请从数据源工作台里的文件列表进入,或检查当前地址是否包含 sourceId 和 id。
`;
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 = `
`;
} 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 = `
`;
});