feat: add docker pipeline and preview fixes
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
const DB_NAME = 'duplicati-browser-secrets';
|
||||
const STORE_NAME = 'source-secrets';
|
||||
const DB_VERSION = 1;
|
||||
const GLOBAL_SOURCE_ID = '__global_defaults__';
|
||||
|
||||
function openDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB.'));
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
database.createObjectStore(STORE_NAME, { keyPath: 'sourceId' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
async function withStore(mode, callback) {
|
||||
const database = await openDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORE_NAME, mode);
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
|
||||
let callbackResult;
|
||||
try {
|
||||
callbackResult = callback(store);
|
||||
} catch (error) {
|
||||
transaction.abort();
|
||||
database.close();
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
transaction.onerror = () => {
|
||||
database.close();
|
||||
reject(transaction.error ?? new Error('IndexedDB transaction failed.'));
|
||||
};
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
database.close();
|
||||
resolve(callbackResult);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadClientSecrets(sourceId) {
|
||||
const database = await openDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORE_NAME, 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.get(sourceId);
|
||||
|
||||
request.onerror = () => {
|
||||
database.close();
|
||||
reject(request.error ?? new Error('Failed to load browser secrets.'));
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
database.close();
|
||||
resolve(request.result ?? null);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadGlobalClientSecrets() {
|
||||
return loadClientSecrets(GLOBAL_SOURCE_ID);
|
||||
}
|
||||
|
||||
export async function saveClientSecrets(sourceId, secrets) {
|
||||
const record = {
|
||||
sourceId,
|
||||
webdavBaseUrl: secrets.webdavBaseUrl,
|
||||
authMode: secrets.authMode,
|
||||
username: secrets.username,
|
||||
password: secrets.password,
|
||||
passphrase: secrets.passphrase,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
return withStore('readwrite', (store) => store.put(record));
|
||||
}
|
||||
|
||||
export async function saveGlobalClientSecrets(secrets) {
|
||||
return saveClientSecrets(GLOBAL_SOURCE_ID, secrets);
|
||||
}
|
||||
|
||||
export async function removeClientSecrets(sourceId) {
|
||||
return withStore('readwrite', (store) => store.delete(sourceId));
|
||||
}
|
||||
|
||||
export async function removeGlobalClientSecrets() {
|
||||
return removeClientSecrets(GLOBAL_SOURCE_ID);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
export function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
export function formatBytes(bytes) {
|
||||
if (bytes === null || bytes === undefined || Number.isNaN(Number(bytes))) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const numeric = Number(bytes);
|
||||
if (numeric === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = numeric;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export async function fetchJson(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${payload.error?.code ?? 'REQUEST_FAILED'}: ${payload.error?.message ?? 'Request failed.'}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function requireQueryParam(name) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const value = params.get(name)?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`MISSING_${name.toUpperCase()}: Query parameter "${name}" is required.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function optionalQueryParam(name, fallback = '') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get(name)?.trim() || fallback;
|
||||
}
|
||||
|
||||
export function createFeedbackController(element) {
|
||||
return {
|
||||
clear() {
|
||||
element.hidden = true;
|
||||
element.className = 'feedback';
|
||||
element.textContent = '';
|
||||
},
|
||||
set(kind, message) {
|
||||
element.hidden = false;
|
||||
element.className = `feedback feedback-${kind}`;
|
||||
element.textContent = message;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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':
|
||||
return '已增强';
|
||||
case 'running':
|
||||
return '增强中';
|
||||
case 'queued':
|
||||
return '排队中';
|
||||
case 'failed':
|
||||
return '增强失败';
|
||||
default:
|
||||
return '未增强';
|
||||
}
|
||||
}
|
||||
|
||||
export function inferEnhancementClass(source) {
|
||||
switch (source?.enhancement?.status) {
|
||||
case 'ready':
|
||||
return 'status-ready';
|
||||
case 'running':
|
||||
case 'queued':
|
||||
return 'status-busy';
|
||||
case 'failed':
|
||||
return 'status-error';
|
||||
default:
|
||||
return 'status-idle';
|
||||
}
|
||||
}
|
||||
|
||||
export function renderSourceSummaryCard(source, extraActionsHtml = '') {
|
||||
const title = escapeHtml(source.displayName || source.originalFilename);
|
||||
const originalFilename = escapeHtml(source.originalFilename);
|
||||
const matchedHint = source.matchedBackupName ? '任务名来自 Server DB' : '未匹配任务名';
|
||||
const progress =
|
||||
source.enhancement.totalVolumes > 0
|
||||
? `${source.enhancement.processedVolumes}/${source.enhancement.totalVolumes}`
|
||||
: '0/0';
|
||||
|
||||
return `
|
||||
<article class="source-card">
|
||||
<div class="source-main">
|
||||
<div>
|
||||
<p class="source-name">${title}</p>
|
||||
<p class="source-meta">原始库名: ${originalFilename}</p>
|
||||
<p class="source-meta">sourceId: ${escapeHtml(source.id)}</p>
|
||||
</div>
|
||||
<span class="status-pill ${inferEnhancementClass(source)}">${inferEnhancementLabel(source)}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-chip">${escapeHtml(matchedHint)}</div>
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="stat">
|
||||
<span>上传大小</span>
|
||||
<strong>${formatBytes(source.fileSize)}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>最近快照</span>
|
||||
<strong>${escapeHtml(source.latestSnapshot?.timestamp ?? '无快照')}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>目录浏览</span>
|
||||
<strong>${source.canBrowse ? '可用' : '不可用'}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>增强进度</span>
|
||||
<strong>${escapeHtml(progress)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${extraActionsHtml}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
export function assertBrowserFeature(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
export function buildThumbnailCandidateTimes(durationSeconds) {
|
||||
const duration = Number(durationSeconds);
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return [0.15];
|
||||
}
|
||||
|
||||
const upperBound = Math.max(0.05, duration - 0.05);
|
||||
const baseCandidates = [0.15, 0.5, 1, 2, 3.5, 5];
|
||||
const candidates = [];
|
||||
|
||||
for (const candidate of baseCandidates) {
|
||||
const clamped = Math.max(0, Math.min(candidate, upperBound));
|
||||
if (candidates.length === 0 || Math.abs(candidates[candidates.length - 1] - clamped) > 0.08) {
|
||||
candidates.push(Number(clamped.toFixed(3)));
|
||||
}
|
||||
}
|
||||
|
||||
if (upperBound > 0.25) {
|
||||
const fallback = Number(upperBound.toFixed(3));
|
||||
if (Math.abs(candidates[candidates.length - 1] - fallback) > 0.08) {
|
||||
candidates.push(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function analyzeFrameLuma(data, options = {}) {
|
||||
const bytes = data instanceof Uint8ClampedArray ? data : new Uint8ClampedArray(data ?? []);
|
||||
const pixelCount = Math.floor(bytes.length / 4);
|
||||
if (pixelCount === 0) {
|
||||
return {
|
||||
sampleCount: 0,
|
||||
averageLuma: 0,
|
||||
darkRatio: 1,
|
||||
isBlackFrame: true
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
darknessThreshold = 18,
|
||||
maxDarkRatio = 0.985,
|
||||
averageThreshold = 22,
|
||||
stride = 16
|
||||
} = options;
|
||||
|
||||
let samples = 0;
|
||||
let darkSamples = 0;
|
||||
let lumaTotal = 0;
|
||||
|
||||
for (let index = 0; index < pixelCount; index += stride) {
|
||||
const offset = index * 4;
|
||||
const red = bytes[offset];
|
||||
const green = bytes[offset + 1];
|
||||
const blue = bytes[offset + 2];
|
||||
const alpha = bytes[offset + 3];
|
||||
const luma = alpha === 0 ? 0 : 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
lumaTotal += luma;
|
||||
samples += 1;
|
||||
|
||||
if (luma <= darknessThreshold) {
|
||||
darkSamples += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const averageLuma = samples > 0 ? lumaTotal / samples : 0;
|
||||
const darkRatio = samples > 0 ? darkSamples / samples : 1;
|
||||
const isBlackFrame = averageLuma <= averageThreshold && darkRatio >= maxDarkRatio;
|
||||
|
||||
return {
|
||||
sampleCount: samples,
|
||||
averageLuma,
|
||||
darkRatio,
|
||||
isBlackFrame
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user