Add chunked, resumable dashcam upload to work around Cloudflare's 100MB body limit

Confirmed against the real deployed server: Cloudflare Tunnel's free
tier caps request bodies at ~100MB and returns a plain 413 - a direct
LAN upload of the same 250MB test file succeeds, the identical request
through yaris.ceaf.dev does not. Real dashcam clips average ~320MB, so
no dashcam video could ever complete an upload through the tunnel as
it stood, regardless of the earlier memory-streaming fix.

Server: new /api/v1/dashcam/upload/chunk (accepts one chunk at a time,
keyed by relative_path+sha256+chunk_index/total_chunks, reassembles and
verifies the sha256 once every chunk has arrived) and
/api/v1/dashcam/upload/status (reports which chunk indices a given
upload already has, or "already_uploaded" if it's done) endpoints.
Chunks are staged under STORAGE_DIR/.upload_tmp/<device>/<sha256>/ and
cleaned up after reassembly; a stale partial set left by a differently
-chunked earlier attempt is detected via a small marker file and wiped
before restarting. The original single-shot /upload endpoint is left
in place unchanged.

Client (modules/app_upload.py): _upload_dashcam_files() now calls
_upload_video_chunked(), which checks /status first (so a prior
partial upload resumes instead of restarting from byte 0) and then
POSTs any missing 40MB chunks (DASHCAM_CHUNK_SIZE, comfortably under
Cloudflare's cap) via the same streaming MultipartEncoder approach as
before.

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-08 22:17:20 -04:00
co-authored by Copilot
parent f88b57d368
commit 7d7be79377
2 changed files with 210 additions and 23 deletions
+68 -23
View File
@@ -20,6 +20,16 @@ they simply wait for connectivity to return, same as before.
*** default (YARIS_CELLULAR_ENABLED unset) so this has zero effect on the
*** already-verified WiFi upload path unless explicitly configured.
Dashcam files are sent in DASHCAM_CHUNK_SIZE pieces via
/api/v1/dashcam/upload/chunk rather than one request per file - confirmed
against the real deployed server that Cloudflare Tunnel's free-tier ~100MB
request-body limit otherwise rejects real dashcam clips (300+MB) outright
with a 413, and that memory-safe streaming alone (MultipartEncoder) doesn't
help there since the *proxy*, not this app, is what enforces the cap.
Chunking also makes uploads resumable: a /status check before sending finds
out which chunks the server already has, so an interrupted transfer picks
back up instead of restarting from byte 0.
Credentials/server URL are loaded from db/upload_creds.env (gitignored,
never commit real values - mirrors the db/creds.env pattern already used by
app_db.py for MongoDB).
@@ -48,6 +58,13 @@ TRACKING_BATCH_SIZE = 500
BACKOFF_INITIAL = 5 # seconds, doubles on each consecutive failure
BACKOFF_MAX = 300
# Real dashcam clips run 300+MB - Cloudflare Tunnel's free-tier ~100MB max
# request body size rejects those outright with a 413 (confirmed against
# the real tunnel: a 250MB file uploads fine direct-LAN but 413s through
# yaris.ceaf.dev). Files are split into chunks this size and sent one at a
# time via /api/v1/dashcam/upload/chunk instead, comfortably under that cap.
DASHCAM_CHUNK_SIZE = 40 * 1024 * 1024
DASHCAM_UPLOAD_SETTING_KEY = "dashcam_upload_enabled"
_upload_thread = None
@@ -161,6 +178,56 @@ def _upload_tracking_batch():
return len(rows)
def _upload_video_chunked(abs_path, rel_path, sha256):
"""Upload one dashcam video file in DASHCAM_CHUNK_SIZE pieces, resuming
from whatever the server already has for this exact (path, sha256)
combo. Raises on any request-level failure - the caller's normal
backlog/backoff handling takes it from there; a retry will simply
re-query /status and resume from the first missing chunk instead of
starting over.
"""
status_resp = requests.get(
f"{SERVER_URL}/api/v1/dashcam/upload/status",
params={"relative_path": rel_path, "sha256": sha256},
headers=_headers(),
timeout=15,
)
status_resp.raise_for_status()
status = status_resp.json()
if status.get("status") == "already_uploaded":
return
already_received = set(status.get("received_chunk_indices", []))
size = os.path.getsize(abs_path)
total_chunks = max(1, -(-size // DASHCAM_CHUNK_SIZE)) # ceil division
with open(abs_path, "rb") as f:
for idx in range(total_chunks):
if idx in already_received:
continue
f.seek(idx * DASHCAM_CHUNK_SIZE)
chunk_bytes = f.read(DASHCAM_CHUNK_SIZE)
encoder = MultipartEncoder(
fields={
"relative_path": rel_path,
"sha256": sha256,
"chunk_index": str(idx),
"total_chunks": str(total_chunks),
"file": (f"chunk_{idx:06d}", chunk_bytes, "application/octet-stream"),
}
)
resp = requests.post(
f"{SERVER_URL}/api/v1/dashcam/upload/chunk",
data=encoder,
headers={**_headers(), "Content-Type": encoder.content_type},
timeout=120,
)
resp.raise_for_status()
def _upload_dashcam_files():
"""Upload any not-yet-uploaded dashcam files, one at a time. Returns count sent.
@@ -176,29 +243,7 @@ def _upload_dashcam_files():
if app_sql.dashcam_file_marked_uploaded(rel_path, sha256):
continue
# Stream the multipart body via MultipartEncoder instead of requests'
# plain files=/data= kwargs. requests builds a non-streamed files=
# upload by reading the *entire* file into memory up front to
# assemble the multipart body - fine for small files, but real
# dashcam clips run 300+MB each, and on a ~1GB-RAM Pi 3 that alone is
# enough to trigger the OOM killer (confirmed against real hardware:
# RSS spiked in lockstep with dashcam file size). MultipartEncoder
# reads the file in small chunks as the request is sent instead.
with open(abs_path, "rb") as f:
encoder = MultipartEncoder(
fields={
"relative_path": rel_path,
"sha256": sha256,
"file": (os.path.basename(abs_path), f, "application/octet-stream"),
}
)
resp = requests.post(
f"{SERVER_URL}/api/v1/dashcam/upload",
data=encoder,
headers={**_headers(), "Content-Type": encoder.content_type},
timeout=300,
)
resp.raise_for_status()
_upload_video_chunked(abs_path, rel_path, sha256)
app_sql.mark_dashcam_file_uploaded(rel_path, sha256)
uploaded_count += 1