diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 7bd098454d..e1f392a057 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -25,7 +25,6 @@ peewee_migrate == 1.14.* psutil == 7.1.* pydantic == 2.10.* git+https://github.com/fbcotter/py3nvml#egg=py3nvml -pytz == 2025.* pyzmq == 26.2.* ruamel.yaml == 0.18.* tzlocal == 5.2 diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index af3a019b4c..0ecd97dc3b 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -2013,7 +2013,7 @@ paths: required: false schema: type: string - default: utc + default: UTC title: Timezone responses: '200': @@ -3055,7 +3055,7 @@ paths: anyOf: - type: string - type: 'null' - default: utc + default: UTC title: Timezone responses: '200': @@ -4153,7 +4153,7 @@ paths: anyOf: - type: string - type: 'null' - default: utc + default: UTC title: Timezone responses: '200': @@ -4405,7 +4405,7 @@ paths: anyOf: - type: string - type: 'null' - default: utc + default: UTC title: Timezone - name: min_score in: query @@ -4485,7 +4485,7 @@ paths: anyOf: - type: string - type: 'null' - default: utc + default: UTC title: Timezone - name: has_clip in: query @@ -6780,7 +6780,7 @@ paths: required: false schema: type: string - default: utc + default: UTC title: Timezone - name: cameras in: query @@ -6830,7 +6830,7 @@ paths: required: false schema: type: string - default: utc + default: UTC title: Timezone responses: '200': diff --git a/frigate/api/defs/query/app_query_parameters.py b/frigate/api/defs/query/app_query_parameters.py index e182a6afda..6eb333aaac 100644 --- a/frigate/api/defs/query/app_query_parameters.py +++ b/frigate/api/defs/query/app_query_parameters.py @@ -9,4 +9,4 @@ class AppTimelineHourlyQueryParameters(BaseModel): after: Optional[float] = None before: Optional[float] = None limit: Optional[int] = 200 - timezone: Optional[str] = "utc" + timezone: Optional[str] = "UTC" diff --git a/frigate/api/defs/query/events_query_parameters.py b/frigate/api/defs/query/events_query_parameters.py index 8e5a5391a9..73c7d8612a 100644 --- a/frigate/api/defs/query/events_query_parameters.py +++ b/frigate/api/defs/query/events_query_parameters.py @@ -41,7 +41,7 @@ class EventsQueryParams(BaseModel): max_length: Optional[float] = None event_id: Optional[str] = None sort: Optional[str] = None - timezone: Optional[str] = "utc" + timezone: Optional[str] = "UTC" class EventsSearchQueryParams(BaseModel): @@ -68,7 +68,7 @@ class EventsSearchQueryParams(BaseModel): has_clip: Optional[bool] = None has_snapshot: Optional[bool] = None is_submitted: Optional[bool] = None - timezone: Optional[str] = "utc" + timezone: Optional[str] = "UTC" min_score: Optional[float] = None max_score: Optional[float] = None min_speed: Optional[float] = None @@ -78,6 +78,6 @@ class EventsSearchQueryParams(BaseModel): class EventsSummaryQueryParams(BaseModel): - timezone: Optional[str] = "utc" + timezone: Optional[str] = "UTC" has_clip: Optional[int] = None has_snapshot: Optional[int] = None diff --git a/frigate/api/defs/query/recordings_query_parameters.py b/frigate/api/defs/query/recordings_query_parameters.py index d4f1b0a7be..a5236d5395 100644 --- a/frigate/api/defs/query/recordings_query_parameters.py +++ b/frigate/api/defs/query/recordings_query_parameters.py @@ -5,7 +5,7 @@ from pydantic.json_schema import SkipJsonSchema class MediaRecordingsSummaryQueryParams(BaseModel): - timezone: str = "utc" + timezone: str = "UTC" cameras: Optional[str] = "all" diff --git a/frigate/api/defs/query/review_query_parameters.py b/frigate/api/defs/query/review_query_parameters.py index 6d01d824d6..242a56b1e3 100644 --- a/frigate/api/defs/query/review_query_parameters.py +++ b/frigate/api/defs/query/review_query_parameters.py @@ -21,7 +21,7 @@ class ReviewSummaryQueryParams(BaseModel): cameras: str = "all" labels: str = "all" zones: str = "all" - timezone: str = "utc" + timezone: str = "UTC" class ReviewActivityMotionQueryParams(BaseModel): diff --git a/frigate/api/media.py b/frigate/api/media.py index 4e01bbebef..6c3b10c54b 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -11,10 +11,10 @@ from datetime import datetime, timedelta, timezone from pathlib import Path as FilePath from typing import Any from urllib.parse import unquote +from zoneinfo import ZoneInfo import cv2 import numpy as np -import pytz from fastapi import APIRouter, Depends, Path, Query, Request, Response from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from pathvalidate import sanitize_filename @@ -720,7 +720,7 @@ async def vod_hour( parts = year_month.split("-") start_date = ( datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=timezone.utc) - - datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset() + - datetime.now(ZoneInfo(tz_name.replace(",", "/"))).utcoffset() ) end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1) start_ts = start_date.timestamp() diff --git a/frigate/api/preview.py b/frigate/api/preview.py index c4d86130ee..2e1300d1ea 100644 --- a/frigate/api/preview.py +++ b/frigate/api/preview.py @@ -5,8 +5,8 @@ import logging import os import threading from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo -import pytz from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import JSONResponse @@ -126,7 +126,7 @@ def preview_hour( parts = year_month.split("-") start_date = ( datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=timezone.utc) - - datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset() + - datetime.now(ZoneInfo(tz_name.replace(",", "/"))).utcoffset() ) end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1) start_ts = start_date.timestamp() diff --git a/frigate/api/record.py b/frigate/api/record.py index 5fc3950e20..f48781415a 100644 --- a/frigate/api/record.py +++ b/frigate/api/record.py @@ -121,7 +121,7 @@ def all_recordings_summary( @router.get( "/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)] ) -async def recordings_summary(camera_name: str, timezone: str = "utc"): +async def recordings_summary(camera_name: str, timezone: str = "UTC"): """Returns hourly summary for recordings of given camera""" time_range_query = ( diff --git a/frigate/record/export.py b/frigate/record/export.py index 16f322e42f..43f7a2edd5 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -12,8 +12,8 @@ import threading from enum import Enum from pathlib import Path from typing import Callable, Optional +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -import pytz # type: ignore[import-untyped] from peewee import DoesNotExist from frigate.config import FfmpegConfig, FrigateConfig @@ -371,8 +371,8 @@ class RecordingExporter(threading.Thread): tz_name = self.config.ui.timezone if tz_name: try: - tz = pytz.timezone(tz_name) - except pytz.UnknownTimeZoneError: + tz = ZoneInfo(tz_name) + except (ValueError, ZoneInfoNotFoundError): tz = None if tz is not None: return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime( @@ -533,8 +533,8 @@ class RecordingExporter(threading.Thread): tz: Optional[datetime.tzinfo] = None if tz_name: try: - tz = pytz.timezone(tz_name) - except pytz.UnknownTimeZoneError: + tz = ZoneInfo(tz_name) + except (ValueError, ZoneInfoNotFoundError): tz = None if tz is None: tz = datetime.timezone.utc diff --git a/frigate/test/http_api/test_http_media.py b/frigate/test/http_api/test_http_media.py index fa4e69473b..6aca2723fb 100644 --- a/frigate/test/http_api/test_http_media.py +++ b/frigate/test/http_api/test_http_media.py @@ -1,8 +1,8 @@ """Unit tests for recordings/media API endpoints.""" from datetime import datetime, timezone +from zoneinfo import ZoneInfo -import pytz from fastapi import Request from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user @@ -51,16 +51,16 @@ class TestHttpMedia(BaseTestHttp): In 2024, DST in America/New_York transitions on March 10, 2024 at 2:00 AM Clocks spring forward from 2:00 AM to 3:00 AM (EST to EDT) """ - tz = pytz.timezone("America/New_York") + tz = ZoneInfo("America/New_York") # March 9, 2024 at 12:00 PM EST (before DST) - march_9_noon = tz.localize(datetime(2024, 3, 9, 12, 0, 0)).timestamp() + march_9_noon = datetime(2024, 3, 9, 12, 0, 0, tzinfo=tz).timestamp() # March 10, 2024 at 12:00 PM EDT (after DST transition) - march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp() + march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp() # March 11, 2024 at 12:00 PM EDT (after DST) - march_11_noon = tz.localize(datetime(2024, 3, 11, 12, 0, 0)).timestamp() + march_11_noon = datetime(2024, 3, 11, 12, 0, 0, tzinfo=tz).timestamp() with AuthTestClient(self.app) as client: # Insert recordings for each day @@ -124,19 +124,16 @@ class TestHttpMedia(BaseTestHttp): In 2024, DST in America/New_York transitions on November 3, 2024 at 2:00 AM Clocks fall back from 2:00 AM to 1:00 AM (EDT to EST) """ - tz = pytz.timezone("America/New_York") + tz = ZoneInfo("America/New_York") # November 2, 2024 at 12:00 PM EDT (before DST transition) - nov_2_noon = tz.localize(datetime(2024, 11, 2, 12, 0, 0)).timestamp() + nov_2_noon = datetime(2024, 11, 2, 12, 0, 0, tzinfo=tz).timestamp() # November 3, 2024 at 12:00 PM EST (after DST transition) - # Need to specify is_dst=False to get the time after fall back - nov_3_noon = tz.localize( - datetime(2024, 11, 3, 12, 0, 0), is_dst=False - ).timestamp() + nov_3_noon = datetime(2024, 11, 3, 12, 0, 0, tzinfo=tz).timestamp() # November 4, 2024 at 12:00 PM EST (after DST) - nov_4_noon = tz.localize(datetime(2024, 11, 4, 12, 0, 0)).timestamp() + nov_4_noon = datetime(2024, 11, 4, 12, 0, 0, tzinfo=tz).timestamp() with AuthTestClient(self.app) as client: # Insert recordings for each day @@ -197,13 +194,13 @@ class TestHttpMedia(BaseTestHttp): """ Test recordings summary with multiple cameras across DST boundary. """ - tz = pytz.timezone("America/New_York") + tz = ZoneInfo("America/New_York") # March 9, 2024 at 10:00 AM EST (before DST) - march_9_morning = tz.localize(datetime(2024, 3, 9, 10, 0, 0)).timestamp() + march_9_morning = datetime(2024, 3, 9, 10, 0, 0, tzinfo=tz).timestamp() # March 10, 2024 at 3:00 PM EDT (after DST transition) - march_10_afternoon = tz.localize(datetime(2024, 3, 10, 15, 0, 0)).timestamp() + march_10_afternoon = datetime(2024, 3, 10, 15, 0, 0, tzinfo=tz).timestamp() with AuthTestClient(self.app) as client: # Override allowed cameras for this test to include both @@ -266,15 +263,15 @@ class TestHttpMedia(BaseTestHttp): """ Test recordings that span the exact DST transition time. """ - tz = pytz.timezone("America/New_York") + tz = ZoneInfo("America/New_York") # March 10, 2024 at 1:00 AM EST (1 hour before DST transition) # At 2:00 AM, clocks jump to 3:00 AM - before_transition = tz.localize(datetime(2024, 3, 10, 1, 0, 0)).timestamp() + before_transition = datetime(2024, 3, 10, 1, 0, 0, tzinfo=tz).timestamp() # Recording that spans the transition (1:00 AM to 3:30 AM EDT) # This is 1.5 hours of actual time but spans the "missing" hour - after_transition = tz.localize(datetime(2024, 3, 10, 3, 30, 0)).timestamp() + after_transition = datetime(2024, 3, 10, 3, 30, 0, tzinfo=tz).timestamp() with AuthTestClient(self.app) as client: Recordings.insert( @@ -334,7 +331,7 @@ class TestHttpMedia(BaseTestHttp): # Test with UTC timezone response = client.get( - "/recordings/summary", params={"timezone": "utc", "cameras": "all"} + "/recordings/summary", params={"timezone": "UTC", "cameras": "all"} ) assert response.status_code == 200 @@ -365,8 +362,8 @@ class TestHttpMedia(BaseTestHttp): """ Test recordings summary filtered to a single camera. """ - tz = pytz.timezone("America/New_York") - march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp() + tz = ZoneInfo("America/New_York") + march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp() with AuthTestClient(self.app) as client: # Insert recordings for both cameras diff --git a/frigate/test/http_api/test_http_review.py b/frigate/test/http_api/test_http_review.py index d13a7bd273..62d4f1608d 100644 --- a/frigate/test/http_api/test_http_review.py +++ b/frigate/test/http_api/test_http_review.py @@ -250,7 +250,7 @@ class TestHttpReview(BaseTestHttp): "cameras": "front_door", "labels": "all", "zones": "all", - "timezone": "utc", + "timezone": "UTC", } response = client.get("/review/summary", params=params) assert response.status_code == 200 diff --git a/frigate/util/time.py b/frigate/util/time.py index 1e7b49c243..3122a36edd 100644 --- a/frigate/util/time.py +++ b/frigate/util/time.py @@ -3,9 +3,8 @@ import datetime import logging from typing import Tuple -from zoneinfo import ZoneInfoNotFoundError +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -import pytz from tzlocal import get_localzone logger = logging.getLogger(__name__) @@ -13,7 +12,7 @@ logger = logging.getLogger(__name__) def get_tz_modifiers(tz_name: str) -> Tuple[str, str, float]: seconds_offset = ( - datetime.datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds() + datetime.datetime.now(ZoneInfo(tz_name)).utcoffset().total_seconds() ) hours_offset = int(seconds_offset / 60 / 60) minutes_offset = int(seconds_offset / 60 - hours_offset * 60) @@ -26,7 +25,7 @@ def get_tomorrow_at_time(hour: int) -> datetime.datetime: """Returns the datetime of the following day at 2am.""" try: tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1) - except ZoneInfoNotFoundError: + except (ValueError, ZoneInfoNotFoundError): tomorrow = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( days=1 ) @@ -52,7 +51,7 @@ def is_current_hour(timestamp: int) -> bool: def get_dst_transitions( tz_name: str, start_time: float, end_time: float -) -> list[tuple[float, float]]: +) -> list[tuple[float, float, int]]: """ Find DST transition points and return time periods with consistent offsets. @@ -66,8 +65,8 @@ def get_dst_transitions( continuous periods with the same UTC offset """ try: - tz = pytz.timezone(tz_name) - except pytz.UnknownTimeZoneError: + tz = ZoneInfo(tz_name) + except (ValueError, ZoneInfoNotFoundError): # If timezone is invalid, return single period with no offset return [(start_time, end_time, 0)] @@ -75,14 +74,14 @@ def get_dst_transitions( current = start_time # Get initial offset - dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC) + dt = datetime.datetime.fromtimestamp(current, tz=datetime.UTC) local_dt = dt.astimezone(tz) prev_offset = local_dt.utcoffset().total_seconds() period_start = start_time # Check each day for offset changes while current <= end_time: - dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC) + dt = datetime.datetime.fromtimestamp(current, tz=datetime.UTC) local_dt = dt.astimezone(tz) current_offset = local_dt.utcoffset().total_seconds()