Content dedup on both sides, stored checksums, spool-free zips, upload stats
ci / docker (push) Successful in 12s

- 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:
2026-07-17 14:41:07 +02:00
parent a6819809a7
commit 6259ca84d8
11 changed files with 296 additions and 60 deletions
+37 -4
View File
@@ -62,7 +62,24 @@ pub async fn process_photo(state: &AppState, photo_id: Uuid) -> anyhow::Result<(
.unwrap_or("bin")
.to_lowercase();
let src_path = dir.path().join(format!("original.{extension}"));
download(state, &s3::original_key(photo_id, &photo.filename), &src_path).await?;
let (sha256, crc32) =
download(state, &s3::original_key(photo_id, &photo.filename), &src_path).await?;
if photo.sha256.is_none() || photo.crc32.is_none() {
// Best-effort backfill for pre-hash photos; a duplicate in the same
// album trips the unique index, which is fine — skip silently.
if let Err(e) = sqlx::query(
"update photos set sha256 = coalesce(sha256, $2), crc32 = coalesce(crc32, $3)
where id = $1",
)
.bind(photo_id)
.bind(&sha256)
.bind(crc32)
.execute(&state.db)
.await
{
tracing::debug!("hash backfill skipped for {photo_id}: {e}");
}
}
let meta = exif_metadata(&src_path).await?;
@@ -103,7 +120,11 @@ pub async fn process_photo(state: &AppState, photo_id: Uuid) -> anyhow::Result<(
Ok(())
}
async fn download(state: &AppState, key: &str, path: &Path) -> anyhow::Result<()> {
/// Download the original, hashing along the way so legacy photos (uploaded
/// before hashes existed) can be backfilled.
async fn download(state: &AppState, key: &str, path: &Path) -> anyhow::Result<(String, i64)> {
use sha2::Digest;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let object = state
.s3
.get_object()
@@ -114,8 +135,20 @@ async fn download(state: &AppState, key: &str, path: &Path) -> anyhow::Result<()
.with_context(|| format!("fetching s3://{}/{key}", state.config.s3_bucket))?;
let mut reader = object.body.into_async_read();
let mut file = tokio::fs::File::create(path).await?;
tokio::io::copy(&mut reader, &mut file).await?;
Ok(())
let mut sha = sha2::Sha256::new();
let mut crc = crc32fast::Hasher::new();
let mut buf = vec![0u8; 128 * 1024];
loop {
let n = reader.read(&mut buf).await?;
if n == 0 {
break;
}
sha.update(&buf[..n]);
crc.update(&buf[..n]);
file.write_all(&buf[..n]).await?;
}
file.flush().await?;
Ok((hex::encode(sha.finalize()), i64::from(crc.finalize())))
}
#[derive(Default)]