diff --git a/package.json b/package.json
index 42be6a0..71dac94 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"test": "node --test",
- "check": "node --check src/server.js && node --check src/db/duplicatiRepository.js && node --check src/db/sourceCatalog.js && node --check src/services/activeSourceService.js && node --check src/services/sourceEnhancementService.js && node --check src/services/authService.js && node --check src/services/tlsService.js && node --check src/lib/aesCrypt.js && node --check src/lib/zip.js && node --check src/lib/auth.js && node --check public/app.js && node --check public/source.js && node --check public/file.js && node --check public/login.js && node --check public/setup.js && node --check public/media-sw.js && node --check public/modules/common.js && node --check public/modules/auth-client.js && node --check public/modules/clientSecrets.js && node --check public/modules/media-core.js && node --check public/modules/thumbnail-utils.js && node --check public/vendor/aes-js-esm.js"
+ "check": "node --check src/server.js && node --check src/db/duplicatiRepository.js && node --check src/db/sourceCatalog.js && node --check src/services/activeSourceService.js && node --check src/services/sourceEnhancementService.js && node --check src/services/authService.js && node --check src/services/tlsService.js && node --check src/lib/aesCrypt.js && node --check src/lib/zip.js && node --check src/lib/auth.js && node --check public/login.js && node --check public/setup.js && node --check public/media-sw.js && node --check public/modules/common.js && node --check public/modules/auth-client.js && node --check public/modules/clientSecrets.js && node --check public/modules/media-core.js && node --check public/modules/thumbnail-utils.js && node --check public/vendor/aes-js-esm.js && node --check public/explorer.js && node --check public/modules/explorer/sidebar.js && node --check public/modules/explorer/fileList.js && node --check public/modules/explorer/mediaPlayer.js && node --check public/modules/explorer/detailPanel.js && node --check public/modules/explorer/manage.js && node --check public/modules/explorer/propsDialog.js"
},
"dependencies": {
"aes-js": "^3.1.2",
diff --git a/public/app.js b/public/app.js
deleted file mode 100644
index 4ded376..0000000
--- a/public/app.js
+++ /dev/null
@@ -1,588 +0,0 @@
-import {
- createFeedbackController,
- escapeHtml,
- fetchJson,
- linkToSource,
- renderSourceSummaryCard
-} from './modules/common.js';
-import { bindLogoutButton, loadAuthState } from './modules/auth-client.js';
-
-const CONTROL_VIEWS = new Set(['overview', 'sources', 'upload', 'server-db', 'defaults', 'tls']);
-
-const state = {
- sources: [],
- serverDb: null,
- globalDefaults: null,
- tls: null,
- currentView: 'overview'
-};
-
-const uploadForm = document.querySelector('#uploadForm');
-const uploadButton = document.querySelector('#uploadButton');
-const databaseInput = document.querySelector('#databaseInput');
-const serverDbForm = document.querySelector('#serverDbForm');
-const serverDbUploadButton = document.querySelector('#serverDbUploadButton');
-const serverDbInput = document.querySelector('#serverDbInput');
-const globalDefaultsForm = document.querySelector('#globalDefaultsForm');
-const globalDefaultsSaveButton = document.querySelector('#globalDefaultsSaveButton');
-const tlsJsonForm = document.querySelector('#tlsJsonForm');
-const tlsUploadForm = document.querySelector('#tlsUploadForm');
-const tlsSaveButton = document.querySelector('#tlsSaveButton');
-const tlsUploadButton = document.querySelector('#tlsUploadButton');
-const tlsDeleteCustomButton = document.querySelector('#tlsDeleteCustomButton');
-const refreshButton = document.querySelector('#refreshButton');
-const serverDbSummaryElement = document.querySelector('#serverDbSummary');
-const globalDefaultsSummaryElement = document.querySelector('#globalDefaultsSummary');
-const tlsSummaryElement = document.querySelector('#tlsSummary');
-const overviewSummaryElement = document.querySelector('#overviewSummary');
-const sourceListElement = document.querySelector('#sourceList');
-const authStatusElement = document.querySelector('#authStatus');
-const logoutButton = document.querySelector('#logoutButton');
-const viewPanels = [...document.querySelectorAll('[data-view-panel]')];
-const viewLinks = [...document.querySelectorAll('[data-view-link]')];
-const feedback = createFeedbackController(document.querySelector('#feedback'));
-
-function normalizeView(value) {
- return CONTROL_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 renderSummaryRows(rows) {
- return `
-
- ${rows
- .map(
- (row) => `
-
- ${escapeHtml(row.label)}
- ${escapeHtml(row.value)}
-
- `
- )
- .join('')}
-
- `;
-}
-
-function readGlobalDefaultsForm() {
- return {
- webdavBaseUrl: document.querySelector('#globalWebdavBaseUrl')?.value?.trim() ?? '',
- authMode: document.querySelector('#globalAuthMode')?.value ?? '',
- username: document.querySelector('#globalUsername')?.value?.trim() ?? '',
- password: document.querySelector('#globalPassword')?.value ?? '',
- passphrase: document.querySelector('#globalPassphrase')?.value ?? ''
- };
-}
-
-function writeGlobalDefaultsForm(defaults) {
- document.querySelector('#globalWebdavBaseUrl').value = defaults?.webdavBaseUrl ?? '';
- document.querySelector('#globalAuthMode').value = defaults?.authMode ?? '';
- document.querySelector('#globalUsername').value = '';
- document.querySelector('#globalPassword').value = '';
- document.querySelector('#globalPassphrase').value = '';
-}
-
-function readTlsJsonForm() {
- return {
- mode: document.querySelector('#tlsMode')?.value ?? 'self-signed',
- primaryDomain: document.querySelector('#tlsPrimaryDomain')?.value?.trim() ?? '',
- subjectAltNames: document.querySelector('#tlsSubjectAltNames')?.value ?? '',
- certPem: document.querySelector('#tlsCertPem')?.value ?? '',
- keyPem: document.querySelector('#tlsKeyPem')?.value ?? '',
- chainPem: document.querySelector('#tlsChainPem')?.value ?? ''
- };
-}
-
-function writeTlsForm(tlsSummary) {
- document.querySelector('#tlsMode').value = tlsSummary?.mode ?? 'self-signed';
- document.querySelector('#tlsPrimaryDomain').value = tlsSummary?.primaryDomain ?? '';
- document.querySelector('#tlsSubjectAltNames').value = Array.isArray(tlsSummary?.subjectAltNames)
- ? tlsSummary.subjectAltNames.join('\n')
- : '';
- document.querySelector('#tlsCertPem').value = '';
- document.querySelector('#tlsKeyPem').value = '';
- document.querySelector('#tlsChainPem').value = '';
-}
-
-function renderOverviewSummary() {
- const sourceCount = state.sources.length;
- const readyCount = state.sources.filter((source) => source.enhancement?.status === 'ready').length;
- const failedCount = state.sources.filter((source) => source.enhancement?.status === 'failed').length;
- const mappedCount = state.sources.filter((source) => source.displayNameSource === 'server-db').length;
-
- overviewSummaryElement.innerHTML = renderSummaryRows([
- { label: '数据源总数', value: String(sourceCount) },
- { label: '已增强数据源', value: String(readyCount) },
- { label: '增强失败', value: String(failedCount) },
- { label: '已映射任务名', value: String(mappedCount) },
- { label: 'Server DB', value: state.serverDb?.available ? state.serverDb.originalFilename : '未上传' },
- { label: '全局 WebDAV', value: state.globalDefaults?.configured ? '已配置' : '未配置' },
- { label: '当前证书来源', value: state.tls?.activeSource ?? '不可用' }
- ]);
-}
-
-function renderServerDbSummary() {
- if (!state.serverDb?.available) {
- serverDbSummaryElement.innerHTML = renderSummaryRows([
- { label: 'Status', value: 'No server database uploaded yet' },
- { label: '说明', value: '未上传 Duplicati-server.sqlite 之前,任务名和推导目标 URL 不可用。' }
- ]);
- return;
- }
-
- serverDbSummaryElement.innerHTML = renderSummaryRows([
- { label: '当前文件', value: state.serverDb.originalFilename },
- { label: '上传时间', value: state.serverDb.uploadedAt },
- { label: '任务条目数', value: String(state.serverDb.backupCount) }
- ]);
-}
-
-function renderGlobalDefaultsSummary() {
- const defaults = state.globalDefaults;
- if (!defaults?.configured) {
- writeGlobalDefaultsForm(null);
- globalDefaultsSummaryElement.innerHTML = renderSummaryRows([
- { label: '状态', value: '尚未保存全局默认值' },
- { label: '说明', value: '如果大多数任务共用同一套 WebDAV 账号和口令,可以在这里统一保存。' }
- ]);
- return;
- }
-
- writeGlobalDefaultsForm(defaults);
- globalDefaultsSummaryElement.innerHTML = renderSummaryRows([
- { label: '兜底 URL', value: defaults.webdavBaseUrl ?? '未设置' },
- { label: '认证方式', value: defaults.authMode ?? '自动' },
- {
- label: '已保存凭据',
- value: `username=${defaults.hasUsername ? 'yes' : 'no'}, password=${defaults.hasPassword ? 'yes' : 'no'}, passphrase=${defaults.hasPassphrase ? 'yes' : 'no'}`
- },
- { label: '更新时间', value: defaults.updatedAt ?? '未知' }
- ]);
-}
-
-function renderTlsSummary() {
- if (!state.tls) {
- tlsSummaryElement.innerHTML = renderSummaryRows([{ label: '状态', value: 'TLS 配置尚未加载' }]);
- return;
- }
-
- writeTlsForm(state.tls);
- tlsDeleteCustomButton.disabled = !state.tls.hasCustomCertificate;
- tlsSummaryElement.innerHTML = renderSummaryRows([
- { label: 'HTTP 访问地址', value: state.tls.access?.httpBaseUrl ?? '未知' },
- { label: 'HTTPS 访问地址', value: state.tls.access?.httpsBaseUrl ?? '未知' },
- { label: '配置模式', value: state.tls.mode ?? 'self-signed' },
- { label: '当前生效证书', value: state.tls.activeSource ?? 'self-signed' },
- { label: '主域名', value: state.tls.primaryDomain || 'localhost' },
- { label: '额外 SAN', value: (state.tls.subjectAltNames ?? []).join(', ') || '无' },
- {
- label: '有效期',
- value: state.tls.certificate ? `${state.tls.certificate.validFrom} -> ${state.tls.certificate.validTo}` : '未知'
- },
- { label: '指纹', value: state.tls.certificate?.fingerprint256 ?? '未知' },
- { label: '最近错误', value: state.tls.lastErrorMessage ?? '无' },
- { label: 'Cookie 说明', value: state.tls.cookieNote ?? 'HTTP/HTTPS 共用登录态' }
- ]);
-}
-
-function renderSources() {
- if (state.sources.length === 0) {
- sourceListElement.innerHTML = `
-
-
No task sources uploaded yet.
-
Upload a Duplicati task database first, then open the source workbench to browse, enhance, preview, or download.
-
- `;
- return;
- }
-
- sourceListElement.innerHTML = state.sources
- .map((source) => {
- const targetHint = source.webdav?.effectiveWebdavBaseUrl
- ? `默认目标 URL 来源:${escapeHtml(source.webdav.effectiveWebdavBaseUrlSource ?? 'unknown')}
`
- : '暂无可用的默认目标 URL
';
-
- return renderSourceSummaryCard(
- source,
- `
- ${targetHint}
-
- `
- );
- })
- .join('');
-}
-
-async function loadServerDbSummary() {
- const payload = await fetchJson('/api/server-db');
- state.serverDb = payload.serverDb;
- renderServerDbSummary();
- renderOverviewSummary();
-}
-
-async function loadGlobalDefaults() {
- const payload = await fetchJson('/api/webdav-defaults');
- state.globalDefaults = payload.defaults;
- renderGlobalDefaultsSummary();
- renderOverviewSummary();
-}
-
-async function loadTlsSummary() {
- const payload = await fetchJson('/api/system/tls');
- state.tls = payload.tls;
- renderTlsSummary();
- renderOverviewSummary();
-}
-
-async function loadSources() {
- const payload = await fetchJson('/api/sources');
- state.sources = payload.sources;
- renderSources();
- renderOverviewSummary();
-}
-
-async function handleTaskDbUpload(event) {
- event.preventDefault();
- feedback.clear();
-
- const file = databaseInput.files?.[0];
- if (!file) {
- feedback.set('error', '请先选择一个任务 SQLite 文件。');
- return;
- }
-
- uploadButton.disabled = true;
- refreshButton.disabled = true;
- feedback.set('info', '正在上传任务数据库...');
-
- try {
- const formData = new FormData();
- formData.set('database', file);
- const response = await fetch('/api/sources/upload', {
- method: 'POST',
- body: formData
- });
- const payload = await response.json();
-
- if (!response.ok) {
- throw new Error(`${payload.error?.code ?? 'UPLOAD_FAILED'}: ${payload.error?.message ?? 'Upload failed.'}`);
- }
-
- await Promise.all([loadSources(), loadServerDbSummary()]);
- uploadForm.reset();
- setCurrentView('sources');
- feedback.set(
- payload.reused ? 'info' : 'success',
- payload.reused
- ? '这个任务数据库之前已经上传过,系统复用了现有 source。'
- : '任务数据库上传成功。'
- );
- } catch (error) {
- feedback.set('error', error.message);
- } finally {
- uploadButton.disabled = false;
- refreshButton.disabled = false;
- }
-}
-
-async function handleServerDbUpload(event) {
- event.preventDefault();
- feedback.clear();
-
- const file = serverDbInput.files?.[0];
- if (!file) {
- feedback.set('error', '请先选择 Duplicati-server.sqlite。');
- return;
- }
-
- serverDbUploadButton.disabled = true;
- feedback.set('info', '正在上传 Server DB 并刷新任务名映射...');
-
- try {
- const formData = new FormData();
- formData.set('database', file);
- const response = await fetch('/api/server-db/upload', {
- method: 'POST',
- body: formData
- });
- const payload = await response.json();
-
- if (!response.ok) {
- throw new Error(`${payload.error?.code ?? 'UPLOAD_FAILED'}: ${payload.error?.message ?? 'Upload failed.'}`);
- }
-
- await Promise.all([loadSources(), loadServerDbSummary()]);
- serverDbForm.reset();
- feedback.set('success', 'Server DB 上传成功。');
- } catch (error) {
- feedback.set('error', error.message);
- } finally {
- serverDbUploadButton.disabled = false;
- }
-}
-
-async function handleGlobalDefaultsSave(event) {
- event.preventDefault();
- feedback.clear();
- globalDefaultsSaveButton.disabled = true;
- feedback.set('info', '正在保存全局 WebDAV 默认值...');
-
- try {
- const payload = await fetchJson('/api/webdav-defaults', {
- method: 'PUT',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify(readGlobalDefaultsForm())
- });
-
- state.globalDefaults = payload.defaults;
- renderGlobalDefaultsSummary();
- renderOverviewSummary();
- await loadSources();
- feedback.set(
- payload.defaults.configured ? 'success' : 'info',
- payload.defaults.configured ? '全局默认值已保存。' : '全局默认值已清空。'
- );
- } catch (error) {
- feedback.set('error', error.message);
- } finally {
- globalDefaultsSaveButton.disabled = false;
- }
-}
-
-async function handleTlsJsonSave(event) {
- event.preventDefault();
- feedback.clear();
- tlsSaveButton.disabled = true;
- tlsUploadButton.disabled = true;
- feedback.set('info', '正在保存 TLS 配置并应用证书...');
-
- try {
- const payload = await fetchJson('/api/system/tls', {
- method: 'PUT',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify(readTlsJsonForm())
- });
- state.tls = payload.tls;
- renderTlsSummary();
- renderOverviewSummary();
- feedback.set('success', `TLS 配置已应用,当前证书来源:${payload.tls.activeSource}。`);
- } catch (error) {
- feedback.set('error', error.message);
- } finally {
- tlsSaveButton.disabled = false;
- tlsUploadButton.disabled = false;
- }
-}
-
-async function handleTlsUpload(event) {
- event.preventDefault();
- feedback.clear();
-
- const certificateFile = document.querySelector('#tlsCertificateInput')?.files?.[0];
- const privateKeyFile = document.querySelector('#tlsPrivateKeyInput')?.files?.[0];
- const chainFile = document.querySelector('#tlsChainInput')?.files?.[0] ?? null;
-
- if (!certificateFile || !privateKeyFile) {
- feedback.set('error', '请同时上传证书文件和私钥文件。');
- return;
- }
-
- tlsSaveButton.disabled = true;
- tlsUploadButton.disabled = true;
- feedback.set('info', '正在上传自定义证书...');
-
- try {
- const formData = new FormData();
- formData.set('primaryDomain', document.querySelector('#tlsPrimaryDomain')?.value?.trim() ?? '');
- formData.set('subjectAltNames', document.querySelector('#tlsSubjectAltNames')?.value ?? '');
- formData.set('certificate', certificateFile);
- formData.set('privateKey', privateKeyFile);
- if (chainFile) {
- formData.set('chain', chainFile);
- }
-
- const response = await fetch('/api/system/tls/upload', {
- method: 'POST',
- body: formData
- });
- const payload = await response.json();
- if (!response.ok) {
- throw new Error(`${payload.error?.code ?? 'TLS_UPLOAD_FAILED'}: ${payload.error?.message ?? 'Upload failed.'}`);
- }
-
- state.tls = payload.tls;
- renderTlsSummary();
- renderOverviewSummary();
- tlsUploadForm.reset();
- feedback.set('success', '自定义证书已上传并生效。');
- } catch (error) {
- feedback.set('error', error.message);
- } finally {
- tlsSaveButton.disabled = false;
- tlsUploadButton.disabled = false;
- }
-}
-
-async function handleDeleteCustomTls() {
- feedback.clear();
- if (!window.confirm('确定要删除当前自定义证书,并回退到自签证书吗?')) {
- return;
- }
-
- tlsDeleteCustomButton.disabled = true;
- feedback.set('info', '正在删除自定义证书,并切回自签证书...');
-
- try {
- const payload = await fetchJson('/api/system/tls/custom-certificate', {
- method: 'DELETE'
- });
- state.tls = payload.tls;
- renderTlsSummary();
- renderOverviewSummary();
- feedback.set('success', '自定义证书已删除,系统已回退到自签证书。');
- } catch (error) {
- feedback.set('error', error.message);
- } finally {
- tlsDeleteCustomButton.disabled = false;
- }
-}
-
-async function deleteSource(sourceId) {
- feedback.clear();
- const source = state.sources.find((item) => item.id === sourceId);
- const label = source?.displayName || source?.originalFilename || sourceId;
- const confirmed = window.confirm(
- `确定要删除这个 source 吗?\n\n${label}\n\n这只会删除当前 source 及其本地增强数据,不会删除 Duplicati-server.sqlite。`
- );
- if (!confirmed) {
- return;
- }
-
- feedback.set('info', `正在删除 ${label}...`);
-
- try {
- const payload = await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`, {
- method: 'DELETE'
- });
- await Promise.all([loadSources(), loadServerDbSummary()]);
- feedback.set('success', `已删除 ${payload.deleted.originalFilename},Server DB 保持不变。`);
- } catch (error) {
- feedback.set('error', error.message);
- }
-}
-
-uploadForm.addEventListener('submit', (event) => {
- void handleTaskDbUpload(event);
-});
-
-serverDbForm.addEventListener('submit', (event) => {
- void handleServerDbUpload(event);
-});
-
-globalDefaultsForm.addEventListener('submit', (event) => {
- void handleGlobalDefaultsSave(event);
-});
-
-tlsJsonForm.addEventListener('submit', (event) => {
- void handleTlsJsonSave(event);
-});
-
-tlsUploadForm.addEventListener('submit', (event) => {
- void handleTlsUpload(event);
-});
-
-tlsDeleteCustomButton.addEventListener('click', () => {
- void handleDeleteCustomTls();
-});
-
-refreshButton.addEventListener('click', () => {
- feedback.clear();
- sourceListElement.textContent = '正在刷新数据源列表...';
- void Promise.all([loadSources(), loadServerDbSummary(), loadGlobalDefaults(), loadTlsSummary()]).catch((error) => {
- feedback.set('error', error.message);
- });
-});
-
-sourceListElement.addEventListener('click', (event) => {
- const target = event.target;
- if (!(target instanceof HTMLElement)) {
- return;
- }
-
- const actionElement = target.closest('[data-delete-source-id]');
- if (!(actionElement instanceof HTMLElement)) {
- return;
- }
-
- const sourceId = actionElement.dataset.deleteSourceId;
- if (!sourceId) {
- return;
- }
-
- void deleteSource(sourceId);
-});
-
-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 });
-});
-
-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);
-
- await Promise.all([loadServerDbSummary(), loadGlobalDefaults(), loadTlsSummary(), loadSources()]);
-}
-
-void bootstrap().catch((error) => {
- feedback.set('error', error.message);
- sourceListElement.innerHTML = `
-
- `;
-});
diff --git a/public/explorer.html b/public/explorer.html
new file mode 100644
index 0000000..75d035f
--- /dev/null
+++ b/public/explorer.html
@@ -0,0 +1,171 @@
+
+
+
+
+
+ 数据源资源管理器
+
+
+
+
+
+
+
+ ▤数据源资源管理器
+
+ 🔎
+
+
+
+ 正在读取登录状态...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
已选 0 项
+
+
+
+ 正在读取目录...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
浏览器直连凭据
+
下载与预览需要在浏览器端直连 WebDAV 解密,请填写目标 URL 和备份口令。勾选"记住"会保存到浏览器 IndexedDB。
+
+
+
+
+
+
+
+
+
diff --git a/public/explorer.js b/public/explorer.js
new file mode 100644
index 0000000..d4015cc
--- /dev/null
+++ b/public/explorer.js
@@ -0,0 +1,583 @@
+import {
+ createFeedbackController,
+ escapeHtml,
+ fetchJson,
+ formatBytes,
+ optionalQueryParam
+} from './modules/common.js';
+import { bindLogoutButton, loadAuthState } from './modules/auth-client.js';
+import { loadClientSecrets, loadGlobalClientSecrets, saveClientSecrets } from './modules/clientSecrets.js';
+import { buildVolumeMap, restoreSegmentBytes, supportsFileDownload } from './modules/media-core.js';
+import { createSidebar } from './modules/explorer/sidebar.js';
+import { createFileList } from './modules/explorer/fileList.js';
+import { createMediaPlayer } from './modules/explorer/mediaPlayer.js';
+import { createDetailPanel } from './modules/explorer/detailPanel.js';
+import { createManage } from './modules/explorer/manage.js';
+import { createPropsDialog } from './modules/explorer/propsDialog.js';
+
+const feedback = createFeedbackController(document.querySelector('#feedback'));
+
+const state = {
+ sourceId: null,
+ source: null,
+ currentPath: '/',
+ currentParent: null,
+ selectedIds: new Set(),
+ fileCount: 0,
+ batchDownloading: false
+};
+
+// ── DOM 引用 ──
+const authStatusElement = document.querySelector('#authStatus');
+const logoutButton = document.querySelector('#logoutButton');
+const sourceTreeElement = document.querySelector('#sourceTree');
+const fileAreaElement = document.querySelector('#fileArea');
+const detailContentElement = document.querySelector('#detailContent');
+const breadcrumbElement = document.querySelector('#breadcrumb');
+const splitElement = document.querySelector('#split');
+const sidebarElement = document.querySelector('#sidebar');
+const sidebarBackdrop = document.querySelector('#sidebarBackdrop');
+const searchInput = document.querySelector('#searchInput');
+const actionBar = document.querySelector('#actionBar');
+const selectAllCheckbox = document.querySelector('#selectAllCheckbox');
+const batchDownloadButton = document.querySelector('#batchDownloadButton');
+const clearSelectionButton = document.querySelector('#clearSelectionButton');
+const selectionCount = document.querySelector('#selectionCount');
+const batchProgress = document.querySelector('#batchProgress');
+const batchProgressContent = document.querySelector('#batchProgressContent');
+const statusItems = document.querySelector('#statusItems');
+const statusSelected = document.querySelector('#statusSelected');
+const toggleDetailButton = document.querySelector('#toggleDetail');
+const thumbnailCanvas = document.querySelector('#thumbnailCaptureCanvas');
+const ctxFile = document.querySelector('#ctxFile');
+const ctxDrive = document.querySelector('#ctxDrive');
+const propsOverlay = document.querySelector('#propsOverlay');
+const propsBody = document.querySelector('#propsBody');
+const manageOverlay = document.querySelector('#manageOverlay');
+const manageBody = document.querySelector('#manageBody');
+const credentialOverlay = document.querySelector('#credentialOverlay');
+const credentialForm = document.querySelector('#credentialForm');
+
+// ── 组件实例 ──
+const sidebar = createSidebar(sourceTreeElement, {
+ onOpenSource: (sourceId) => void openSource(sourceId),
+ onContextMenu: (sourceId, pos) => showDriveMenu(sourceId, pos)
+});
+
+const mediaPlayer = createMediaPlayer({
+ canvas: thumbnailCanvas,
+ onThumbnail: ({ fileId, thumbnail }) => applyThumbnailToList(fileId, thumbnail)
+});
+
+const detailPanel = createDetailPanel(detailContentElement, {
+ mediaPlayer,
+ feedback,
+ resolveSecrets: (sourceId, fileInfo) => resolveSecrets(sourceId, fileInfo),
+ onOpenProps: (sourceId) => openProps(sourceId),
+ onThumbnailUpdated: (fileId, thumbnail) => applyThumbnailToList(fileId, thumbnail)
+});
+
+const fileList = createFileList(fileAreaElement, {
+ onOpenDir: (path) => void loadDirectory(path),
+ onOpenFile: (entry) => void openFile(entry),
+ onSelectFile: (entry) => void detailPanel.showFile(state.sourceId, entry),
+ onSelectionChange: (selected, fileCount) => onSelectionChange(selected, fileCount),
+ onContextMenu: (entry, pos) => showFileMenu(entry, pos)
+});
+
+const manage = createManage(manageBody, {
+ feedback,
+ onSourcesChanged: (sources) => sidebar.setSources(sources),
+ onOpenSource: (sourceId) => {
+ closeOverlay(manageOverlay);
+ void openSource(sourceId);
+ },
+ close: () => closeOverlay(manageOverlay)
+});
+
+const propsDialog = createPropsDialog(propsBody, {
+ feedback,
+ onSourceChanged: (source) => {
+ if (source.id === state.sourceId) {
+ state.source = source;
+ }
+ void reloadSources();
+ },
+ onSourceDeleted: (sourceId) => {
+ void handleSourceDeleted(sourceId);
+ },
+ close: () => closeOverlay(propsOverlay)
+});
+
+// ── 凭据解析 ──
+async function resolveSecrets(sourceId, fileInfo) {
+ const [sourceSecrets, globalSecrets] = await Promise.all([
+ loadClientSecrets(sourceId),
+ loadGlobalClientSecrets()
+ ]);
+ const resolved = sourceSecrets ?? globalSecrets;
+ if (resolved?.webdavBaseUrl && resolved?.passphrase) {
+ return resolved;
+ }
+ // 缺凭据 → 弹窗收集
+ return promptForCredentials(sourceId, fileInfo, resolved);
+}
+
+function promptForCredentials(sourceId, fileInfo, partial) {
+ return new Promise((resolve, reject) => {
+ document.querySelector('#credWebdavBaseUrl').value =
+ partial?.webdavBaseUrl ?? fileInfo?.source?.webdav?.effectiveWebdavBaseUrl ?? state.source?.webdav?.effectiveWebdavBaseUrl ?? '';
+ document.querySelector('#credAuthMode').value = partial?.authMode ?? 'basic';
+ document.querySelector('#credUsername').value = partial?.username ?? '';
+ document.querySelector('#credPassword').value = partial?.password ?? '';
+ document.querySelector('#credPassphrase').value = partial?.passphrase ?? '';
+ document.querySelector('#credRemember').checked = false;
+ openOverlay(credentialOverlay);
+
+ function cleanup() {
+ credentialForm.removeEventListener('submit', onSubmit);
+ document.querySelector('#credCancel').removeEventListener('click', onCancel);
+ }
+
+ async function onSubmit(event) {
+ event.preventDefault();
+ const secrets = {
+ webdavBaseUrl: document.querySelector('#credWebdavBaseUrl').value.trim(),
+ authMode: document.querySelector('#credAuthMode').value,
+ username: document.querySelector('#credUsername').value.trim(),
+ password: document.querySelector('#credPassword').value,
+ passphrase: document.querySelector('#credPassphrase').value
+ };
+ if (!secrets.webdavBaseUrl || !secrets.passphrase) {
+ feedback.set('error', '请填写 WebDAV Base URL 和备份口令。');
+ return;
+ }
+ if (document.querySelector('#credRemember').checked) {
+ await saveClientSecrets(sourceId, secrets);
+ }
+ cleanup();
+ closeOverlay(credentialOverlay);
+ resolve(secrets);
+ }
+
+ function onCancel() {
+ cleanup();
+ closeOverlay(credentialOverlay);
+ const error = new Error('CREDENTIALS_CANCELLED: 已取消凭据输入。');
+ error.code = 'CREDENTIALS_CANCELLED';
+ reject(error);
+ }
+
+ credentialForm.addEventListener('submit', onSubmit);
+ document.querySelector('#credCancel').addEventListener('click', onCancel);
+ });
+}
+
+// ── 数据加载 ──
+async function reloadSources() {
+ const payload = await fetchJson('/api/sources');
+ sidebar.setSources(payload.sources);
+ return payload.sources;
+}
+
+async function openSource(sourceId) {
+ if (!sourceId) {
+ return;
+ }
+ feedback.clear();
+ state.sourceId = sourceId;
+ sidebar.setActive(sourceId);
+ closeSidebarOnMobile();
+
+ try {
+ const payload = await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`);
+ state.source = payload.source;
+ void detailPanel.showSource(payload.source);
+ await loadDirectory('/');
+ } catch (error) {
+ feedback.set('error', error.message);
+ fileAreaElement.innerHTML = `${escapeHtml(error.message)}
`;
+ }
+}
+
+async function loadDirectory(path) {
+ if (!state.sourceId) {
+ return;
+ }
+ feedback.clear();
+ fileAreaElement.innerHTML = '正在读取目录...
';
+
+ try {
+ const payload = await fetchJson(
+ `/api/ls?sourceId=${encodeURIComponent(state.sourceId)}&path=${encodeURIComponent(path)}`
+ );
+ state.currentPath = payload.path;
+ state.currentParent = payload.parent;
+ renderBreadcrumb(payload.path);
+ fileList.setEntries(payload.entries);
+ updateStatusBar();
+ } catch (error) {
+ feedback.set('error', error.message);
+ fileAreaElement.innerHTML = `无法列出目录内容。
${escapeHtml(error.message)}
`;
+ }
+}
+
+async function openFile(entry) {
+ // 双击:视频直接预览,其它文件在详情面板展示
+ await detailPanel.showFile(state.sourceId, entry);
+ fileList.setActiveFile(entry.id);
+ setDetailVisible(true);
+}
+
+async function handleSourceDeleted(sourceId) {
+ const sources = await reloadSources();
+ if (state.sourceId === sourceId) {
+ state.sourceId = null;
+ state.source = null;
+ void detailPanel.clear();
+ fileAreaElement.innerHTML = '数据源已删除,请从左侧选择其它数据源。
';
+ breadcrumbElement.textContent = '/';
+ updateStatusBar();
+ if (sources.length > 0) {
+ void openSource(sources[0].id);
+ }
+ }
+}
+
+// ── 面包屑 ──
+function renderBreadcrumb(path) {
+ const segments = path === '/' ? [] : path.split('/').filter(Boolean);
+ const crumbs = [''];
+ let acc = '';
+ for (const segment of segments) {
+ acc += `/${segment}`;
+ crumbs.push('▸');
+ crumbs.push(``);
+ }
+ breadcrumbElement.innerHTML = crumbs.join('');
+}
+
+// ── 选择与批量下载 ──
+function onSelectionChange(selected, fileCount) {
+ state.selectedIds = selected;
+ state.fileCount = fileCount;
+ const count = selected.size;
+ actionBar.hidden = fileCount === 0;
+ selectionCount.textContent = `已选 ${count} 项`;
+ batchDownloadButton.disabled = count === 0 || state.batchDownloading || !supportsFileDownload();
+ if (!supportsFileDownload()) {
+ batchDownloadButton.title = '当前浏览器不支持 showSaveFilePicker';
+ }
+ selectAllCheckbox.checked = count > 0 && count === fileCount;
+ selectAllCheckbox.indeterminate = count > 0 && count < fileCount;
+ updateStatusBar();
+}
+
+function updateStatusBar() {
+ const entries = fileList.getEntries();
+ const dirs = entries.filter((entry) => entry.type === 'dir').length;
+ const files = entries.filter((entry) => entry.type === 'file').length;
+ statusItems.textContent = `${entries.length} 项(${dirs} 个文件夹,${files} 个文件)`;
+ statusSelected.textContent = state.selectedIds.size > 0 ? `已选 ${state.selectedIds.size} 项` : '';
+}
+
+async function initiateBatchDownload() {
+ if (!supportsFileDownload() || typeof window.showDirectoryPicker !== 'function') {
+ feedback.set('error', '当前浏览器不支持批量下载(需要 showDirectoryPicker)。');
+ return;
+ }
+ if (state.selectedIds.size === 0) {
+ return;
+ }
+
+ let secrets;
+ try {
+ secrets = await resolveSecrets(state.sourceId, null);
+ } catch (error) {
+ if (error?.code !== 'CREDENTIALS_CANCELLED') {
+ feedback.set('error', error.message);
+ }
+ return;
+ }
+
+ let directoryHandle;
+ try {
+ directoryHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
+ } catch {
+ feedback.set('info', '已取消目录选择。');
+ return;
+ }
+
+ const fileIds = [...state.selectedIds];
+ const total = fileIds.length;
+ state.batchDownloading = true;
+ batchDownloadButton.disabled = true;
+ batchProgress.hidden = false;
+ const errors = [];
+ let totalWritten = 0;
+
+ for (let i = 0; i < fileIds.length; i += 1) {
+ const fileId = fileIds[i];
+ try {
+ const info = await fetchJson(
+ `/api/file-info?sourceId=${encodeURIComponent(state.sourceId)}&id=${encodeURIComponent(fileId)}`
+ );
+ renderBatchProgress(i + 1, total, info.file.name, totalWritten);
+
+ const safeName = info.file.name.replace(/[/\\:*?"<>|]/g, '_');
+ const fileHandle = await directoryHandle.getFileHandle(safeName, { create: true });
+ const writable = await fileHandle.createWritable();
+ const volumeByRef = buildVolumeMap(info.volumes);
+
+ for (const segment of info.segments) {
+ const volume = volumeByRef.get(segment.volumeRef);
+ const bytes = await restoreSegmentBytes({
+ sourceId: state.sourceId,
+ volume,
+ zip: segment.zip,
+ secrets,
+ onStatus: ({ phase, currentVolume }) => {
+ renderBatchProgress(i + 1, total, info.file.name, totalWritten, `${phase ?? ''} ${currentVolume ?? ''}`);
+ }
+ });
+ await writable.write(bytes);
+ totalWritten += bytes.length;
+ }
+ await writable.close();
+ } catch (error) {
+ errors.push(error.message);
+ }
+ }
+
+ state.batchDownloading = false;
+ batchProgress.hidden = true;
+ onSelectionChange(state.selectedIds, state.fileCount);
+
+ if (errors.length === 0) {
+ feedback.set('success', `已完成全部 ${total} 个文件的批量下载。`);
+ fileList.clearSelection();
+ } else {
+ feedback.set('error', `批量下载完成:${total - errors.length}/${total} 成功。失败:${errors.join('; ')}`);
+ }
+}
+
+function renderBatchProgress(current, total, fileName, written, phase = '') {
+ batchProgressContent.innerHTML = `
+
+
进度${current} / ${total}
+
当前文件${escapeHtml(fileName)}
+ ${phase ? `
阶段${escapeHtml(phase)}
` : ''}
+
已写入${formatBytes(written)}
+
+ `;
+}
+
+function applyThumbnailToList(fileId, thumbnail) {
+ const entries = fileList.getEntries();
+ const entry = entries.find((item) => item.id === fileId);
+ if (entry) {
+ entry.thumbnail = thumbnail;
+ fileList.setEntries(entries);
+ }
+}
+
+// ── 右键菜单 ──
+function hideMenus() {
+ ctxFile.hidden = true;
+ ctxDrive.hidden = true;
+}
+
+function positionMenu(menu, { x, y }) {
+ hideMenus();
+ menu.hidden = false;
+ const width = menu.offsetWidth || 200;
+ const height = menu.offsetHeight || 220;
+ menu.style.left = `${Math.min(x, window.innerWidth - width - 8)}px`;
+ menu.style.top = `${Math.min(y, window.innerHeight - height - 8)}px`;
+}
+
+let ctxTargetEntry = null;
+let ctxTargetSourceId = null;
+
+function showFileMenu(entry, pos) {
+ if (entry.type !== 'file') {
+ return;
+ }
+ ctxTargetEntry = entry;
+ positionMenu(ctxFile, pos);
+}
+
+function showDriveMenu(sourceId, pos) {
+ ctxTargetSourceId = sourceId;
+ positionMenu(ctxDrive, pos);
+}
+
+ctxFile.addEventListener('click', (event) => {
+ const action = event.target.closest('[data-action]')?.dataset.action;
+ hideMenus();
+ if (!ctxTargetEntry) {
+ return;
+ }
+ if (action === 'open' || action === 'preview') {
+ void openFile(ctxTargetEntry);
+ } else if (action === 'download') {
+ void detailPanel.showFile(state.sourceId, ctxTargetEntry).then(() => {
+ // 详情面板加载后,用户可点下载;这里直接触发一次选择即可
+ });
+ fileList.setActiveFile(ctxTargetEntry.id);
+ setDetailVisible(true);
+ } else if (action === 'details') {
+ void detailPanel.showFile(state.sourceId, ctxTargetEntry);
+ fileList.setActiveFile(ctxTargetEntry.id);
+ setDetailVisible(true);
+ }
+});
+
+ctxDrive.addEventListener('click', (event) => {
+ const action = event.target.closest('[data-action]')?.dataset.action;
+ hideMenus();
+ if (!ctxTargetSourceId) {
+ return;
+ }
+ if (action === 'open') {
+ void openSource(ctxTargetSourceId);
+ } else if (action === 'refresh') {
+ if (state.sourceId === ctxTargetSourceId) {
+ void loadDirectory(state.currentPath);
+ } else {
+ void openSource(ctxTargetSourceId);
+ }
+ } else if (action === 'props') {
+ openProps(ctxTargetSourceId);
+ } else if (action === 'delete') {
+ openProps(ctxTargetSourceId, 'danger');
+ }
+});
+
+document.addEventListener('click', (event) => {
+ if (!event.target.closest('.explorer-context-menu')) {
+ hideMenus();
+ }
+});
+window.addEventListener('scroll', hideMenus, true);
+
+// ── 弹窗 ──
+function openOverlay(overlay) {
+ overlay.hidden = false;
+}
+function closeOverlay(overlay) {
+ overlay.hidden = true;
+}
+
+function openProps(sourceId, tab = 'enhance') {
+ openOverlay(propsOverlay);
+ void propsDialog.open(sourceId, tab);
+}
+
+for (const overlay of [propsOverlay, manageOverlay, credentialOverlay]) {
+ overlay.addEventListener('click', (event) => {
+ if (event.target === overlay && overlay !== credentialOverlay) {
+ closeOverlay(overlay);
+ }
+ });
+}
+
+// ── 详情面板显隐 ──
+function setDetailVisible(visible) {
+ splitElement.classList.toggle('with-detail', visible);
+ toggleDetailButton.textContent = visible ? '隐藏详情面板 ▸' : '显示详情面板 ◂';
+}
+toggleDetailButton.addEventListener('click', () => {
+ setDetailVisible(!splitElement.classList.contains('with-detail'));
+});
+
+// ── 侧栏(移动端抽屉) ──
+function openSidebarMobile() {
+ sidebarElement.classList.add('open');
+ sidebarBackdrop.hidden = false;
+}
+function closeSidebarOnMobile() {
+ sidebarElement.classList.remove('open');
+ sidebarBackdrop.hidden = true;
+}
+document.querySelector('#sidebarToggle').addEventListener('click', () => {
+ if (sidebarElement.classList.contains('open')) {
+ closeSidebarOnMobile();
+ } else {
+ openSidebarMobile();
+ }
+});
+sidebarBackdrop.addEventListener('click', closeSidebarOnMobile);
+
+// ── 管理入口 ──
+for (const link of document.querySelectorAll('[data-manage]')) {
+ link.addEventListener('click', () => {
+ closeSidebarOnMobile();
+ openOverlay(manageOverlay);
+ void manage.open(link.dataset.manage === 'sources' ? 'sources' : link.dataset.manage);
+ });
+}
+
+// ── 导航条 ──
+document.querySelector('#navUp').addEventListener('click', () => {
+ if (state.currentParent !== null && state.currentParent !== undefined) {
+ void loadDirectory(state.currentParent);
+ } else if (state.currentPath !== '/') {
+ void loadDirectory('/');
+ }
+});
+document.querySelector('#navRoot').addEventListener('click', () => void loadDirectory('/'));
+document.querySelector('#navRefresh').addEventListener('click', () => void loadDirectory(state.currentPath));
+
+breadcrumbElement.addEventListener('click', (event) => {
+ const crumb = event.target.closest('[data-path]');
+ if (crumb instanceof HTMLElement) {
+ void loadDirectory(crumb.dataset.path);
+ }
+});
+
+// ── 视图 / 排序 / 搜索 ──
+document.querySelector('#viewList').addEventListener('click', () => {
+ fileList.setView('list');
+ document.querySelector('#viewList').classList.add('active');
+ document.querySelector('#viewGrid').classList.remove('active');
+});
+document.querySelector('#viewGrid').addEventListener('click', () => {
+ fileList.setView('grid');
+ document.querySelector('#viewGrid').classList.add('active');
+ document.querySelector('#viewList').classList.remove('active');
+});
+document.querySelector('#sortSelect').addEventListener('change', (event) => {
+ const [key, dir] = event.target.value.split(':');
+ fileList.setSort(key, dir);
+});
+searchInput.addEventListener('input', (event) => {
+ fileList.setFilter(event.target.value);
+});
+
+// ── 选择操作条 ──
+selectAllCheckbox.addEventListener('change', (event) => fileList.selectAll(event.target.checked));
+clearSelectionButton.addEventListener('click', () => fileList.clearSelection());
+batchDownloadButton.addEventListener('click', () => void initiateBatchDownload());
+
+// ── 启动 ──
+async function bootstrap() {
+ const authentication = await loadAuthState();
+ authStatusElement.textContent = authentication.authenticated ? `已登录:${authentication.username}` : '未登录';
+ bindLogoutButton(logoutButton, authStatusElement);
+
+ const sources = await reloadSources();
+ const requestedSource = optionalQueryParam('sourceId', '');
+ const target = sources.find((source) => source.id === requestedSource) ?? sources[0];
+ if (target) {
+ await openSource(target.id);
+ } else {
+ fileAreaElement.innerHTML = '还没有数据源。点左侧「上传任务库」开始。
';
+ void detailPanel.clear();
+ }
+}
+
+void bootstrap().catch((error) => {
+ feedback.set('error', error.message);
+ fileAreaElement.innerHTML = '无法进入资源管理器。
';
+});
diff --git a/public/file.html b/public/file.html
deleted file mode 100644
index 98d8bfc..0000000
--- a/public/file.html
+++ /dev/null
@@ -1,176 +0,0 @@
-
-
-
-
-
- 文件预览与下载
-
-
-
-
-
-
-
-
-
File Workspace
-
文件预览与下载
-
-
- 这里负责浏览器直连 WebDAV 的视频预览、下载、本地缓存和预览图回写。大流量文件流依然只走前端,不经过后端文件代理。
-
-
-
-
- 正在读取登录状态...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 只有增强完成、并且 MIME 为视频的文件,才会在这里启用浏览器端 Range 播放。
-
-
- 这里删除的是后端缩略图缓存,不会删除浏览器本地视频缓存,也不会影响当前视频文件本身。
-
-
-
正在计算当前任务的浏览器默认值...
-
-
-
-
-
-
-
-
-
-
-
-
-
- 下载会复用视频预览相同的浏览器直连配置和 dblock 缓存,顺序读取所需分段并直接写盘,不把整个文件拼进内存。
-
-
尚未开始下载。
-
-
-
-
-
-
-
尚未注册媒体会话。
-
正在统计浏览器本地缓存...
-
-
-
-
-
-
-
-
-
-
-
- 视频第一次成功预览后,会自动截取可用画面并回写到后端。目录浏览页会直接使用这里缓存的缩略图。
-
-
正在读取预览图状态...
-
-
-
-
-
-
-
-
-
-
diff --git a/public/file.js b/public/file.js
deleted file mode 100644
index 65d52df..0000000
--- a/public/file.js
+++ /dev/null
@@ -1,1317 +0,0 @@
-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 = ``;
-}
-
-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 });
- 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 = `
-
- `;
-});
diff --git a/public/index.html b/public/index.html
deleted file mode 100644
index 865a752..0000000
--- a/public/index.html
+++ /dev/null
@@ -1,231 +0,0 @@
-
-
-
-
-
- 数据源控制台
-
-
-
-
-
-
-
Zero-Bandwidth Duplicati Web Client
-
数据源控制台
-
-
- 这里负责管理任务数据库、Server DB 元数据、全局 WebDAV 默认值,以及站点级 HTTPS/TLS 配置。
- 目录浏览、增强、预览和下载继续放在单个数据源工作台与文件页里。
-
-
-
-
- 正在读取登录状态...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 每个上传的 Duplicati 任务数据库都会变成一个独立 source。进入工作台后,可以继续浏览目录、执行增强,或跳转到文件页做预览和下载。
-
-
正在读取数据源列表...
-
-
-
-
-
-
-
- 上传随机名任务数据库,例如 TMQRJYNADS.sqlite。重复上传同一个数据库时,系统会复用现有 source,而不是重复创建。
-
-
-
-
-
-
-
-
-
- 上传后,界面可以自动解析任务名称,并尽量从 Duplicati 元数据中推导默认目标 URL。
-
-
-
正在读取 Server DB 状态...
-
-
-
-
-
-
-
- 如果大多数任务共用同一套 WebDAV 认证和口令,可以在这里统一保存一次。
-
-
-
正在读取 WebDAV 默认值...
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/modules/common.js b/public/modules/common.js
index aee764f..3371355 100644
--- a/public/modules/common.js
+++ b/public/modules/common.js
@@ -80,14 +80,6 @@ export function createFeedbackController(element) {
};
}
-export function linkToSource(sourceId) {
- return `/source.html?sourceId=${encodeURIComponent(sourceId)}`;
-}
-
-export function linkToFile(sourceId, fileId) {
- return `/file.html?sourceId=${encodeURIComponent(sourceId)}&id=${encodeURIComponent(fileId)}`;
-}
-
export function inferEnhancementLabel(source) {
switch (source?.enhancement?.status) {
case 'ready':
diff --git a/public/modules/explorer/detailPanel.js b/public/modules/explorer/detailPanel.js
new file mode 100644
index 0000000..fc99fcc
--- /dev/null
+++ b/public/modules/explorer/detailPanel.js
@@ -0,0 +1,271 @@
+import { escapeHtml, fetchJson, formatBytes, inferEnhancementClass, inferEnhancementLabel } from '../common.js';
+import { buildVolumeMap, isVideoMime, restoreSegmentBytes, supportsFileDownload } from '../media-core.js';
+
+// 右侧详情面板:
+// - 选中文件 → 属性 + 内嵌