diff --git a/web/static/js/app.js b/web/static/js/app.js index 2260698..09d8717 100755 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -3,11 +3,12 @@ import { element } from "./dom.js"; import { destinationSelect, setAutoExtractDefault, setupDownloadForm, updateSubfolderState } from "./download-form.js"; import { formatBytes } from "./format.js"; import { recentDestinations, renderHistory } from "./history.js"; -import { isActive, matchesFilter, renderJobs, updateSpeeds } from "./jobs.js"; +import { isActive, matchesFilter, renderJobs } from "./jobs.js"; import { fillLibrarySelect } from "./libraries.js"; import { setupLogin } from "./login.js"; import { collectTransitions, notifyTransitions } from "./notify.js"; import { loadSettings, openSettings, setupSettings } from "./settings.js"; +import { updateSpeeds } from "./speed.js"; import { renderTree, topLevelSelection } from "./tree.js"; const pollIntervalMs = 1500; diff --git a/web/static/js/format.js b/web/static/js/format.js index 9ed57e2..0649671 100755 --- a/web/static/js/format.js +++ b/web/static/js/format.js @@ -22,14 +22,24 @@ export function formatBytes(bytes) { return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; } -export function formatDuration(seconds) { - const total = Math.max(0, Math.round(seconds)); - const hours = Math.floor(total / 3600); - const minutes = Math.floor((total % 3600) / 60); - const remainingSeconds = total % 60; - if (hours > 0) return `${hours}h ${minutes}m`; - if (minutes > 0) return `${minutes}m ${remainingSeconds}s`; - return `${remainingSeconds}s`; +function roundTo(value, step) { + return Math.max(step, Math.round(value / step) * step); +} + +export function formatRemaining(seconds) { + if (!Number.isFinite(seconds) || seconds <= 0) return "almost done"; + if (seconds < 60) return `${roundTo(seconds, 5)}s`; + if (seconds < 600) { + const rounded = roundTo(seconds, 15); + const minutes = Math.floor(rounded / 60); + const remainingSeconds = rounded % 60; + return remainingSeconds === 0 ? `${minutes}m` : `${minutes}m ${remainingSeconds}s`; + } + if (seconds < 3600) return `${roundTo(seconds / 60, 1)}m`; + const totalMinutes = roundTo(seconds / 60, 5); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`; } export function formatDate(value) { diff --git a/web/static/js/jobs.js b/web/static/js/jobs.js index 6f3f050..df5cd66 100755 --- a/web/static/js/jobs.js +++ b/web/static/js/jobs.js @@ -1,10 +1,9 @@ import { button, element } from "./dom.js"; -import { formatBytes, formatDuration, percentOf, statusLabels } from "./format.js"; +import { formatBytes, formatRemaining, percentOf, statusLabels } from "./format.js"; +import { speedOf } from "./speed.js"; const activeStatuses = new Set(["queued", "downloading", "extracting", "moving"]); const pausableStatuses = new Set(["queued", "downloading"]); -const speedSmoothing = 0.3; -const speedSamples = new Map(); export function isActive(job) { return activeStatuses.has(job.status); @@ -16,26 +15,6 @@ export function matchesFilter(job, filter) { return job.status === filter; } -export function updateSpeeds(jobs) { - const now = performance.now(); - const seenIds = new Set(); - - for (const job of jobs) { - seenIds.add(job.id); - const previous = speedSamples.get(job.id); - let speed = 0; - if (previous && job.bytesDone >= previous.bytes && now > previous.time) { - const instantSpeed = ((job.bytesDone - previous.bytes) * 1000) / (now - previous.time); - speed = previous.speed > 0 ? previous.speed + speedSmoothing * (instantSpeed - previous.speed) : instantSpeed; - } - speedSamples.set(job.id, { bytes: job.bytesDone, time: now, speed }); - } - - for (const id of speedSamples.keys()) { - if (!seenIds.has(id)) speedSamples.delete(id); - } -} - function displayRank(job) { if (isActive(job) && !job.queuePosition) return 0; if (job.queuePosition) return 1; @@ -110,11 +89,11 @@ function progressText(job, percent) { } if (percent !== null) parts.push(`${percent.toFixed(1)}%`); - const sample = speedSamples.get(job.id); - if (isActive(job) && sample && sample.speed > 0) { - parts.push(`${formatBytes(sample.speed)}/s`); + const speed = speedOf(job.id); + if (isActive(job) && speed > 0) { + parts.push(`${formatBytes(speed)}/s`); if (job.bytesTotal > job.bytesDone) { - parts.push(`${formatDuration((job.bytesTotal - job.bytesDone) / sample.speed)} left`); + parts.push(`${formatRemaining((job.bytesTotal - job.bytesDone) / speed)} left`); } } return parts.join(" | "); diff --git a/web/static/js/speed.js b/web/static/js/speed.js new file mode 100644 index 0000000..dc9471e --- /dev/null +++ b/web/static/js/speed.js @@ -0,0 +1,39 @@ +const windowMs = 30000; +const minimumSpanMs = 4000; +const samplesByJob = new Map(); + +export function updateSpeeds(jobs) { + const now = performance.now(); + const seenIds = new Set(); + + for (const job of jobs) { + seenIds.add(job.id); + let samples = samplesByJob.get(job.id) || []; + const last = samples[samples.length - 1]; + const progressWentBack = last && job.bytesDone < last.bytes; + const notTransferring = job.status !== "downloading" && job.status !== "extracting" && job.status !== "moving"; + if (progressWentBack || notTransferring || (last && last.status !== job.status)) { + samples = []; + } + + samples.push({ bytes: job.bytesDone, time: now, status: job.status }); + while (samples.length > 2 && now - samples[1].time >= windowMs) { + samples.shift(); + } + samplesByJob.set(job.id, samples); + } + + for (const id of samplesByJob.keys()) { + if (!seenIds.has(id)) samplesByJob.delete(id); + } +} + +export function speedOf(jobId) { + const samples = samplesByJob.get(jobId); + if (!samples || samples.length < 2) return 0; + const first = samples[0]; + const last = samples[samples.length - 1]; + const spanMs = last.time - first.time; + if (spanMs < minimumSpanMs) return 0; + return ((last.bytes - first.bytes) * 1000) / spanMs; +}