Content dedup on both sides, stored checksums, spool-free zips, upload stats
ci / docker (push) Successful in 16s
ci / docker (push) Successful in 16s
- sha256+crc32 hashed during upload streaming; unique index per album - duplicate content returns the existing photo (race-safe via 23505) - client hashes locally (WebCrypto) and skips the transfer entirely for content the album already has - zip downloads stream S3->response directly using the stored crc32; pre-hash photos spool once and self-heal (crc via zip, sha via reprocess) - upload UI: overall progress bar, bytes, live speed, ETA
This commit is contained in:
@@ -73,6 +73,13 @@ export function postDownload(url, ids = '') {
|
||||
form.remove()
|
||||
}
|
||||
|
||||
// SHA-256 of a File, matching the server's content hash — used to skip
|
||||
// uploading bytes the album already has.
|
||||
export async function sha256Hex(file) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
|
||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export function uploadFile(url, file, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
@@ -1,21 +1,57 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api, postDownload, uploadFile } from '../api'
|
||||
import { api, postDownload, sha256Hex, uploadFile } from '../api'
|
||||
import Gallery from '../components/Gallery'
|
||||
import Lightbox from '../components/Lightbox'
|
||||
import SelectionBar from '../components/SelectionBar'
|
||||
import SelectionBar, { fmtBytes } from '../components/SelectionBar'
|
||||
import Stars from '../components/Stars'
|
||||
import useSelection from '../useSelection'
|
||||
|
||||
function fmtEta(seconds) {
|
||||
if (!isFinite(seconds) || seconds < 0) return ''
|
||||
if (seconds < 60) return `${Math.ceil(seconds)}s`
|
||||
if (seconds < 3600) return `${Math.ceil(seconds / 60)} min`
|
||||
return `${Math.floor(seconds / 3600)}h ${Math.ceil((seconds % 3600) / 60)} min`
|
||||
}
|
||||
|
||||
const UPLOAD_CONCURRENCY = 3
|
||||
|
||||
function UploadZone({ albumId, onUploaded }) {
|
||||
const [queue, setQueue] = useState([])
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [speed, setSpeed] = useState(0)
|
||||
const inputRef = useRef(null)
|
||||
const running = useRef(0)
|
||||
const pending = useRef([])
|
||||
const lastRefresh = useRef(0)
|
||||
const loadedRef = useRef(0)
|
||||
|
||||
const totalBytes = queue.reduce((sum, item) => sum + item.file.size, 0)
|
||||
const loadedBytes = queue.reduce(
|
||||
(sum, item) =>
|
||||
sum + (item.status === 'done' ? item.file.size : (item.progress || 0) * item.file.size),
|
||||
0,
|
||||
)
|
||||
loadedRef.current = loadedBytes
|
||||
const active = queue.some((item) =>
|
||||
['uploading', 'queued', 'checking'].includes(item.status),
|
||||
)
|
||||
|
||||
// Sample throughput once a second (EMA-smoothed) while uploads run.
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setSpeed(0)
|
||||
return
|
||||
}
|
||||
let last = { loaded: loadedRef.current, time: Date.now() }
|
||||
const timer = setInterval(() => {
|
||||
const now = Date.now()
|
||||
const instant = (loadedRef.current - last.loaded) / ((now - last.time) / 1000)
|
||||
last = { loaded: loadedRef.current, time: now }
|
||||
setSpeed((prev) => (prev > 0 ? prev * 0.7 + instant * 0.3 : instant))
|
||||
}, 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [active])
|
||||
|
||||
// Refresh the album at most every 5s during a bulk upload (the processing
|
||||
// poll keeps it fresh anyway), plus once when the queue drains.
|
||||
@@ -31,23 +67,27 @@ function UploadZone({ albumId, onUploaded }) {
|
||||
while (running.current < UPLOAD_CONCURRENCY && pending.current.length > 0) {
|
||||
const item = pending.current.shift()
|
||||
running.current += 1
|
||||
setQueue((q) =>
|
||||
q.map((x) => (x.key === item.key ? { ...x, status: 'uploading' } : x)),
|
||||
)
|
||||
const url = `/api/albums/${albumId}/photos?filename=${encodeURIComponent(item.file.name)}`
|
||||
uploadFile(url, item.file, (p) =>
|
||||
setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, progress: p } : x))),
|
||||
)
|
||||
.then(() =>
|
||||
setQueue((q) =>
|
||||
q.map((x) => (x.key === item.key ? { ...x, status: 'done', progress: 1 } : x)),
|
||||
),
|
||||
)
|
||||
.catch((e) =>
|
||||
setQueue((q) =>
|
||||
q.map((x) => (x.key === item.key ? { ...x, status: 'error', error: e.message } : x)),
|
||||
),
|
||||
)
|
||||
const update = (patch) =>
|
||||
setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, ...patch } : x)))
|
||||
const transfer = async () => {
|
||||
// Hash locally first: content the album already has is skipped
|
||||
// without transferring a single byte.
|
||||
update({ status: 'checking' })
|
||||
try {
|
||||
const hash = await sha256Hex(item.file)
|
||||
await api(`/api/albums/${albumId}/photos/by-hash/${hash}`)
|
||||
update({ status: 'skipped', progress: 1 })
|
||||
return
|
||||
} catch {
|
||||
// 404 (not there yet) or hashing unavailable — upload normally.
|
||||
}
|
||||
update({ status: 'uploading' })
|
||||
const url = `/api/albums/${albumId}/photos?filename=${encodeURIComponent(item.file.name)}`
|
||||
await uploadFile(url, item.file, (p) => update({ progress: p }))
|
||||
update({ status: 'done', progress: 1 })
|
||||
}
|
||||
transfer()
|
||||
.catch((e) => update({ status: 'error', error: e.message }))
|
||||
.finally(() => {
|
||||
running.current -= 1
|
||||
refresh()
|
||||
@@ -95,6 +135,22 @@ function UploadZone({ albumId, onUploaded }) {
|
||||
}}
|
||||
/>
|
||||
<p>Drop RAWs or JPGs here, or click to select</p>
|
||||
{queue.length > 0 && (
|
||||
<div className="upload-summary" onClick={(e) => e.stopPropagation()}>
|
||||
<progress className="upload-total" value={loadedBytes} max={totalBytes || 1} />
|
||||
<span className="muted">
|
||||
{queue.filter((i) => i.status === 'done' || i.status === 'skipped').length} /{' '}
|
||||
{queue.length} files ·{' '}
|
||||
{fmtBytes(loadedBytes)} of {fmtBytes(totalBytes)}
|
||||
{active && speed > 0 && (
|
||||
<>
|
||||
{' · '}
|
||||
{fmtBytes(speed)}/s · ~{fmtEta((totalBytes - loadedBytes) / speed)} left
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{queue.length > 0 && (
|
||||
<ul className="upload-list" onClick={(e) => e.stopPropagation()}>
|
||||
{queue.map((item) => (
|
||||
@@ -102,6 +158,10 @@ function UploadZone({ albumId, onUploaded }) {
|
||||
<span className="upload-name">{item.file.name}</span>
|
||||
{item.status === 'error' ? (
|
||||
<span className="error">{item.error}</span>
|
||||
) : item.status === 'skipped' ? (
|
||||
<span className="muted">already uploaded</span>
|
||||
) : item.status === 'checking' ? (
|
||||
<span className="muted">checking…</span>
|
||||
) : (
|
||||
<progress value={item.progress} max="1" />
|
||||
)}
|
||||
|
||||
@@ -307,6 +307,18 @@ input:focus {
|
||||
.upload-zone p {
|
||||
margin: 0;
|
||||
}
|
||||
.upload-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-top: 1rem;
|
||||
cursor: default;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.upload-total {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.upload-list {
|
||||
list-style: none;
|
||||
margin: 1rem 0 0;
|
||||
|
||||
Reference in New Issue
Block a user