mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-06-04 01:16:52 +02: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