From 8baed7170161ed254a12902618586db550941ce9 Mon Sep 17 00:00:00 2001 From: nils Date: Fri, 17 Jul 2026 14:41:07 +0200 Subject: [PATCH] Content dedup on both sides, stored checksums, spool-free zips, upload stats - 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 --- Cargo.lock | 1 + Cargo.toml | 1 + frontend/src/api.js | 7 ++ frontend/src/pages/AlbumPage.jsx | 98 ++++++++++++++++++++++------ frontend/src/styles.css | 12 ++++ migrations/0002_content_hash.sql | 10 +++ src/imaging.rs | 41 ++++++++++-- src/models.rs | 4 ++ src/routes/mod.rs | 1 + src/routes/photos.rs | 108 +++++++++++++++++++++++-------- src/routes/zip.rs | 73 +++++++++++++++++---- 11 files changed, 296 insertions(+), 60 deletions(-) create mode 100644 migrations/0002_content_hash.sql diff --git a/Cargo.lock b/Cargo.lock index c0a5e73..3ac1d15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2562,6 +2562,7 @@ dependencies = [ "cookie", "crc32fast", "futures", + "hex", "image", "rand 0.8.7", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 8c52e9b..34bc187 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ axum = { version = "0.8", features = ["macros"] } axum-extra = { version = "0.10", features = ["cookie", "cookie-signed"] } chrono = { version = "0.4", features = ["serde"] } futures = "0.3" +hex = "0.4" image = "0.25" rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } diff --git a/frontend/src/api.js b/frontend/src/api.js index d935ba4..aee167c 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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() diff --git a/frontend/src/pages/AlbumPage.jsx b/frontend/src/pages/AlbumPage.jsx index fee6032..816b742 100644 --- a/frontend/src/pages/AlbumPage.jsx +++ b/frontend/src/pages/AlbumPage.jsx @@ -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 }) { }} />

Drop RAWs or JPGs here, or click to select

+ {queue.length > 0 && ( +
e.stopPropagation()}> + + + {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 + + )} + +
+ )} {queue.length > 0 && (