Set modified time on case zip entries (#24803)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s

* set modified time on case download zip entries

Case download zip entries were written with the zip format's 1980 placeholder date, so extracted files showed a blank or 1980 modified date. Each entry now uses the export's recording start time, converted with the UI timezone since zip times have no zone and extractors read them as local time.

* clamp case zip entry dates to 1980
This commit is contained in:
Josh Hawkins
2026-10-26 13:17:48 -05:00
committed by GitHub
parent 0dc05305ae
commit 9769741713
2 changed files with 62 additions and 5 deletions
+29 -4
View File
@@ -12,6 +12,7 @@ from collections import deque
from collections.abc import Iterator from collections.abc import Iterator
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import psutil import psutil
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
@@ -81,7 +82,7 @@ from frigate.record.export import (
) )
from frigate.util.path import sanitize_contained_path from frigate.util.path import sanitize_contained_path
from frigate.util.recording_coverage import recording_overlap from frigate.util.recording_coverage import recording_overlap
from frigate.util.time import is_current_hour from frigate.util.time import get_normalized_tz_name, is_current_hour
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -442,7 +443,29 @@ def _unique_archive_name(export: Export, used: set[str]) -> str:
return candidate return candidate
def _stream_case_archive(exports: list[Export]) -> Iterator[bytes]: def _archive_entry(
export: Export, arcname: str, tz_name: str | None
) -> zipfile.ZipInfo:
"""Zip entry stamped with the export's recording start time.
Zip times carry no zone and extractors read them as local time, so the
timestamp is converted with the UI timezone rather than the container's.
"""
tz = None
if tz_name:
try:
tz = ZoneInfo(get_normalized_tz_name(tz_name))
except (ValueError, ZoneInfoNotFoundError):
pass
start = datetime.datetime.fromtimestamp(float(export.date), tz=tz)
# zip can't represent dates before 1980
date_time = max(start.timetuple()[:6], (1980, 1, 1, 0, 0, 0))
return zipfile.ZipInfo(arcname, date_time=date_time)
def _stream_case_archive(exports: list[Export], tz_name: str | None) -> Iterator[bytes]:
"""Yield bytes of a zip archive built from the given exports' mp4 files.""" """Yield bytes of a zip archive built from the given exports' mp4 files."""
buffer = _StreamingZipBuffer() buffer = _StreamingZipBuffer()
used_names: set[str] = set() used_names: set[str] = set()
@@ -460,9 +483,10 @@ def _stream_case_archive(exports: list[Export]) -> Iterator[bytes]:
continue continue
arcname = _unique_archive_name(export, used_names) arcname = _unique_archive_name(export, used_names)
zinfo = _archive_entry(export, arcname, tz_name)
with ( with (
archive.open(arcname, mode="w", force_zip64=True) as entry, archive.open(zinfo, mode="w", force_zip64=True) as entry,
source.open("rb") as src, source.open("rb") as src,
): ):
while True: while True:
@@ -501,6 +525,7 @@ def _content_disposition(filename: str, ascii_fallback: str) -> str:
description="Streams a zip archive containing every completed export's mp4 for the given case.", description="Streams a zip archive containing every completed export's mp4 for the given case.",
) )
def download_export_case( def download_export_case(
request: Request,
case_id: str, case_id: str,
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
): ):
@@ -533,7 +558,7 @@ def download_export_case(
archive_base = case_id archive_base = case_id
return StreamingResponse( return StreamingResponse(
_stream_case_archive(exports), _stream_case_archive(exports, request.app.frigate_config.ui.timezone),
media_type="application/zip", media_type="application/zip",
headers={ headers={
"Content-Disposition": _content_disposition( "Content-Disposition": _content_disposition(
+33 -1
View File
@@ -4,7 +4,7 @@ import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from frigate.api.export import _unique_archive_name from frigate.api.export import _archive_entry, _unique_archive_name
from frigate.const import MAX_PLAYLIST_SECONDS from frigate.const import MAX_PLAYLIST_SECONDS
from frigate.models import Export from frigate.models import Export
from frigate.record.export import ( from frigate.record.export import (
@@ -220,6 +220,38 @@ class TestUniqueArchiveName(unittest.TestCase):
) )
class TestArchiveEntry(unittest.TestCase):
"""Tests for the modified time stamped on case download zip entries."""
def build_export(self) -> Export:
return Export(
id="front_door_abc123",
camera="front_door",
name="front door",
date=1756000000.0,
video_path="/media/frigate/exports/front_door_abc123.mp4",
thumb_path="/media/frigate/exports/front_door_abc123.webp",
in_progress=False,
)
def test_uses_start_time_in_ui_timezone(self):
zinfo = _archive_entry(self.build_export(), "a.mp4", "America/New_York")
self.assertEqual(zinfo.date_time, (2025, 8, 23, 21, 46, 40))
def test_clamps_dates_before_1980(self):
export = self.build_export()
export.date = 100
zinfo = _archive_entry(export, "a.mp4", "UTC")
self.assertEqual(zinfo.date_time, (1980, 1, 1, 0, 0, 0))
def test_invalid_timezone_falls_back_to_local_time(self):
zinfo = _archive_entry(self.build_export(), "a.mp4", "Not/A_Real_Zone")
self.assertEqual(
zinfo.date_time,
_archive_entry(self.build_export(), "a.mp4", None).date_time,
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()