mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Use UTC for recordings (#4656)
* Write files in UTC and update folder structure to not conflict * Add timezone arg for events summary * Fixes for timezone in calls * Use timezone for recording and recordings summary endpoints * Fix sqlite parsing * Fix sqlite parsing * Fix recordings summary with timezone * Fix * Formatting * Add pytz * Fix default timezone * Add note about times being displayed in localtime * Improve timezone wording and show actual timezone * Add alternate endpoint to cover existing usecase to avoid breaking change * Formatting
This commit is contained in:
		
							parent
							
								
									739a267462
								
							
						
					
					
						commit
						037f3761e7
					
				@ -1,15 +1,18 @@
 | 
				
			|||||||
import base64
 | 
					import base64
 | 
				
			||||||
from datetime import datetime, timedelta
 | 
					from datetime import datetime, timedelta, timezone
 | 
				
			||||||
import copy
 | 
					import copy
 | 
				
			||||||
import glob
 | 
					import glob
 | 
				
			||||||
import logging
 | 
					import logging
 | 
				
			||||||
import json
 | 
					import json
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
import subprocess as sp
 | 
					import subprocess as sp
 | 
				
			||||||
 | 
					import pytz
 | 
				
			||||||
import time
 | 
					import time
 | 
				
			||||||
import traceback
 | 
					import traceback
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from functools import reduce
 | 
					from functools import reduce
 | 
				
			||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					from tzlocal import get_localzone_name
 | 
				
			||||||
from urllib.parse import unquote
 | 
					from urllib.parse import unquote
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import cv2
 | 
					import cv2
 | 
				
			||||||
@ -86,6 +89,8 @@ def is_healthy():
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
@bp.route("/events/summary")
 | 
					@bp.route("/events/summary")
 | 
				
			||||||
def events_summary():
 | 
					def events_summary():
 | 
				
			||||||
 | 
					    tz_name = request.args.get("timezone", default="utc", type=str)
 | 
				
			||||||
 | 
					    tz_offset = f"{int(datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds()/60/60)} hour"
 | 
				
			||||||
    has_clip = request.args.get("has_clip", type=int)
 | 
					    has_clip = request.args.get("has_clip", type=int)
 | 
				
			||||||
    has_snapshot = request.args.get("has_snapshot", type=int)
 | 
					    has_snapshot = request.args.get("has_snapshot", type=int)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -105,7 +110,7 @@ def events_summary():
 | 
				
			|||||||
            Event.camera,
 | 
					            Event.camera,
 | 
				
			||||||
            Event.label,
 | 
					            Event.label,
 | 
				
			||||||
            fn.strftime(
 | 
					            fn.strftime(
 | 
				
			||||||
                "%Y-%m-%d", fn.datetime(Event.start_time, "unixepoch", "localtime")
 | 
					                "%Y-%m-%d", fn.datetime(Event.start_time, "unixepoch", tz_offset)
 | 
				
			||||||
            ).alias("day"),
 | 
					            ).alias("day"),
 | 
				
			||||||
            Event.zones,
 | 
					            Event.zones,
 | 
				
			||||||
            fn.COUNT(Event.id).alias("count"),
 | 
					            fn.COUNT(Event.id).alias("count"),
 | 
				
			||||||
@ -115,7 +120,7 @@ def events_summary():
 | 
				
			|||||||
            Event.camera,
 | 
					            Event.camera,
 | 
				
			||||||
            Event.label,
 | 
					            Event.label,
 | 
				
			||||||
            fn.strftime(
 | 
					            fn.strftime(
 | 
				
			||||||
                "%Y-%m-%d", fn.datetime(Event.start_time, "unixepoch", "localtime")
 | 
					                "%Y-%m-%d", fn.datetime(Event.start_time, "unixepoch", tz_offset)
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
            Event.zones,
 | 
					            Event.zones,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
@ -796,11 +801,13 @@ def get_recordings_storage_usage():
 | 
				
			|||||||
# return hourly summary for recordings of camera
 | 
					# return hourly summary for recordings of camera
 | 
				
			||||||
@bp.route("/<camera_name>/recordings/summary")
 | 
					@bp.route("/<camera_name>/recordings/summary")
 | 
				
			||||||
def recordings_summary(camera_name):
 | 
					def recordings_summary(camera_name):
 | 
				
			||||||
 | 
					    tz_name = request.args.get("timezone", default="utc", type=str)
 | 
				
			||||||
 | 
					    tz_offset = f"{int(datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds()/60/60)} hour"
 | 
				
			||||||
    recording_groups = (
 | 
					    recording_groups = (
 | 
				
			||||||
        Recordings.select(
 | 
					        Recordings.select(
 | 
				
			||||||
            fn.strftime(
 | 
					            fn.strftime(
 | 
				
			||||||
                "%Y-%m-%d %H",
 | 
					                "%Y-%m-%d %H",
 | 
				
			||||||
                fn.datetime(Recordings.start_time, "unixepoch", "localtime"),
 | 
					                fn.datetime(Recordings.start_time, "unixepoch", tz_offset),
 | 
				
			||||||
            ).alias("hour"),
 | 
					            ).alias("hour"),
 | 
				
			||||||
            fn.SUM(Recordings.duration).alias("duration"),
 | 
					            fn.SUM(Recordings.duration).alias("duration"),
 | 
				
			||||||
            fn.SUM(Recordings.motion).alias("motion"),
 | 
					            fn.SUM(Recordings.motion).alias("motion"),
 | 
				
			||||||
@ -810,13 +817,13 @@ def recordings_summary(camera_name):
 | 
				
			|||||||
        .group_by(
 | 
					        .group_by(
 | 
				
			||||||
            fn.strftime(
 | 
					            fn.strftime(
 | 
				
			||||||
                "%Y-%m-%d %H",
 | 
					                "%Y-%m-%d %H",
 | 
				
			||||||
                fn.datetime(Recordings.start_time, "unixepoch", "localtime"),
 | 
					                fn.datetime(Recordings.start_time, "unixepoch", tz_offset),
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        .order_by(
 | 
					        .order_by(
 | 
				
			||||||
            fn.strftime(
 | 
					            fn.strftime(
 | 
				
			||||||
                "%Y-%m-%d H",
 | 
					                "%Y-%m-%d H",
 | 
				
			||||||
                fn.datetime(Recordings.start_time, "unixepoch", "localtime"),
 | 
					                fn.datetime(Recordings.start_time, "unixepoch", tz_offset),
 | 
				
			||||||
            ).desc()
 | 
					            ).desc()
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
@ -824,14 +831,16 @@ def recordings_summary(camera_name):
 | 
				
			|||||||
    event_groups = (
 | 
					    event_groups = (
 | 
				
			||||||
        Event.select(
 | 
					        Event.select(
 | 
				
			||||||
            fn.strftime(
 | 
					            fn.strftime(
 | 
				
			||||||
                "%Y-%m-%d %H", fn.datetime(Event.start_time, "unixepoch", "localtime")
 | 
					                "%Y-%m-%d %H",
 | 
				
			||||||
 | 
					                fn.datetime(Event.start_time, "unixepoch", tz_offset),
 | 
				
			||||||
            ).alias("hour"),
 | 
					            ).alias("hour"),
 | 
				
			||||||
            fn.COUNT(Event.id).alias("count"),
 | 
					            fn.COUNT(Event.id).alias("count"),
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        .where(Event.camera == camera_name, Event.has_clip)
 | 
					        .where(Event.camera == camera_name, Event.has_clip)
 | 
				
			||||||
        .group_by(
 | 
					        .group_by(
 | 
				
			||||||
            fn.strftime(
 | 
					            fn.strftime(
 | 
				
			||||||
                "%Y-%m-%d %H", fn.datetime(Event.start_time, "unixepoch", "localtime")
 | 
					                "%Y-%m-%d %H",
 | 
				
			||||||
 | 
					                fn.datetime(Event.start_time, "unixepoch", tz_offset),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        .objects()
 | 
					        .objects()
 | 
				
			||||||
@ -1016,8 +1025,20 @@ def vod_ts(camera_name, start_ts, end_ts):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@bp.route("/vod/<year_month>/<day>/<hour>/<camera_name>")
 | 
					@bp.route("/vod/<year_month>/<day>/<hour>/<camera_name>")
 | 
				
			||||||
def vod_hour(year_month, day, hour, camera_name):
 | 
					def vod_hour_no_timezone(year_month, day, hour, camera_name):
 | 
				
			||||||
    start_date = datetime.strptime(f"{year_month}-{day} {hour}", "%Y-%m-%d %H")
 | 
					    return vod_hour(
 | 
				
			||||||
 | 
					        year_month, day, hour, camera_name, get_localzone_name().replace("/", "_")
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# TODO make this nicer when vod module is removed
 | 
				
			||||||
 | 
					@bp.route("/vod/<year_month>/<day>/<hour>/<camera_name>/<tz_name>")
 | 
				
			||||||
 | 
					def vod_hour(year_month, day, hour, camera_name, tz_name):
 | 
				
			||||||
 | 
					    tz_name = tz_name.replace("_", "/")
 | 
				
			||||||
 | 
					    parts = year_month.split("-")
 | 
				
			||||||
 | 
					    start_date = datetime(
 | 
				
			||||||
 | 
					        int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=pytz.timezone(tz_name)
 | 
				
			||||||
 | 
					    ).astimezone(timezone.utc)
 | 
				
			||||||
    end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
 | 
					    end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
 | 
				
			||||||
    start_ts = start_date.timestamp()
 | 
					    start_ts = start_date.timestamp()
 | 
				
			||||||
    end_ts = end_date.timestamp()
 | 
					    end_ts = end_date.timestamp()
 | 
				
			||||||
 | 
				
			|||||||
@ -261,8 +261,8 @@ class RecordingMaintainer(threading.Thread):
 | 
				
			|||||||
    def store_segment(
 | 
					    def store_segment(
 | 
				
			||||||
        self,
 | 
					        self,
 | 
				
			||||||
        camera,
 | 
					        camera,
 | 
				
			||||||
        start_time,
 | 
					        start_time: datetime.datetime,
 | 
				
			||||||
        end_time,
 | 
					        end_time: datetime.datetime,
 | 
				
			||||||
        duration,
 | 
					        duration,
 | 
				
			||||||
        cache_path,
 | 
					        cache_path,
 | 
				
			||||||
        store_mode: RetainModeEnum,
 | 
					        store_mode: RetainModeEnum,
 | 
				
			||||||
@ -277,12 +277,20 @@ class RecordingMaintainer(threading.Thread):
 | 
				
			|||||||
            self.end_time_cache.pop(cache_path, None)
 | 
					            self.end_time_cache.pop(cache_path, None)
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        directory = os.path.join(RECORD_DIR, start_time.strftime("%Y-%m/%d/%H"), camera)
 | 
					        directory = os.path.join(
 | 
				
			||||||
 | 
					            RECORD_DIR,
 | 
				
			||||||
 | 
					            start_time.replace(tzinfo=datetime.timezone.utc)
 | 
				
			||||||
 | 
					            .astimezone(tz=None)
 | 
				
			||||||
 | 
					            .strftime("%Y-%m-%d/%H"),
 | 
				
			||||||
 | 
					            camera,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if not os.path.exists(directory):
 | 
					        if not os.path.exists(directory):
 | 
				
			||||||
            os.makedirs(directory)
 | 
					            os.makedirs(directory)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        file_name = f"{start_time.strftime('%M.%S.mp4')}"
 | 
					        file_name = (
 | 
				
			||||||
 | 
					            f"{start_time.replace(tzinfo=datetime.timezone.utc).strftime('%M.%S.mp4')}"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        file_path = os.path.join(directory, file_name)
 | 
					        file_path = os.path.join(directory, file_name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
 | 
				
			|||||||
@ -11,6 +11,8 @@ peewee_migrate == 1.4.*
 | 
				
			|||||||
psutil == 5.9.*
 | 
					psutil == 5.9.*
 | 
				
			||||||
pydantic == 1.10.*
 | 
					pydantic == 1.10.*
 | 
				
			||||||
PyYAML == 6.0
 | 
					PyYAML == 6.0
 | 
				
			||||||
 | 
					pytz == 2022.6
 | 
				
			||||||
 | 
					tzlocal == 4.2
 | 
				
			||||||
types-PyYAML == 6.0.*
 | 
					types-PyYAML == 6.0.*
 | 
				
			||||||
requests == 2.28.*
 | 
					requests == 2.28.*
 | 
				
			||||||
types-requests == 2.28.*
 | 
					types-requests == 2.28.*
 | 
				
			||||||
 | 
				
			|||||||
@ -9,13 +9,16 @@ import { useApiHost } from '../api';
 | 
				
			|||||||
import useSWR from 'swr';
 | 
					import useSWR from 'swr';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function Recording({ camera, date, hour = '00', minute = '00', second = '00' }) {
 | 
					export default function Recording({ camera, date, hour = '00', minute = '00', second = '00' }) {
 | 
				
			||||||
 | 
					  const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
 | 
				
			||||||
  const currentDate = useMemo(
 | 
					  const currentDate = useMemo(
 | 
				
			||||||
    () => (date ? parseISO(`${date}T${hour || '00'}:${minute || '00'}:${second || '00'}`) : new Date()),
 | 
					    () => (date ? parseISO(`${date}T${hour || '00'}:${minute || '00'}:${second || '00'}`) : new Date()),
 | 
				
			||||||
    [date, hour, minute, second]
 | 
					    [date, hour, minute, second]
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const apiHost = useApiHost();
 | 
					  const apiHost = useApiHost();
 | 
				
			||||||
  const { data: recordingsSummary } = useSWR(`${camera}/recordings/summary`, { revalidateOnFocus: false });
 | 
					  const { data: recordingsSummary } = useSWR([`${camera}/recordings/summary`, { timezone }], {
 | 
				
			||||||
 | 
					    revalidateOnFocus: false,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const recordingParams = {
 | 
					  const recordingParams = {
 | 
				
			||||||
    before: getUnixTime(endOfHour(currentDate)),
 | 
					    before: getUnixTime(endOfHour(currentDate)),
 | 
				
			||||||
@ -66,14 +69,17 @@ export default function Recording({ camera, date, hour = '00', minute = '00', se
 | 
				
			|||||||
          description: `${camera} recording @ ${h.hour}:00.`,
 | 
					          description: `${camera} recording @ ${h.hour}:00.`,
 | 
				
			||||||
          sources: [
 | 
					          sources: [
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
              src: `${apiHost}/vod/${year}-${month}/${day}/${h.hour}/${camera}/master.m3u8`,
 | 
					              src: `${apiHost}/vod/${year}-${month}/${day}/${h.hour}/${camera}/${timezone.replaceAll(
 | 
				
			||||||
 | 
					                '/',
 | 
				
			||||||
 | 
					                '_'
 | 
				
			||||||
 | 
					              )}/master.m3u8`,
 | 
				
			||||||
              type: 'application/vnd.apple.mpegurl',
 | 
					              type: 'application/vnd.apple.mpegurl',
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
          ],
 | 
					          ],
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
      })
 | 
					      })
 | 
				
			||||||
      .reverse();
 | 
					      .reverse();
 | 
				
			||||||
  }, [apiHost, date, recordingsSummary, camera]);
 | 
					  }, [apiHost, date, recordingsSummary, camera, timezone]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const playlistIndex = useMemo(() => {
 | 
					  const playlistIndex = useMemo(() => {
 | 
				
			||||||
    const index = playlist.findIndex((item) => item.name === hour);
 | 
					    const index = playlist.findIndex((item) => item.name === hour);
 | 
				
			||||||
@ -126,6 +132,7 @@ export default function Recording({ camera, date, hour = '00', minute = '00', se
 | 
				
			|||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className="space-y-4 p-2 px-4">
 | 
					    <div className="space-y-4 p-2 px-4">
 | 
				
			||||||
      <Heading>{camera.replaceAll('_', ' ')} Recordings</Heading>
 | 
					      <Heading>{camera.replaceAll('_', ' ')} Recordings</Heading>
 | 
				
			||||||
 | 
					      <div className="text-xs">Dates and times are based on the browser's timezone {timezone}</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <VideoPlayer
 | 
					      <VideoPlayer
 | 
				
			||||||
        onReady={(player) => {
 | 
					        onReady={(player) => {
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user