mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-02-18 00:16:41 +01:00
* config file changes * config migrator * stream selection on single camera live view * camera streaming settings dialog * manage persistent group streaming settings * apply streaming settings in camera groups * add ability to clear all streaming settings from settings * docs * update reference config * fixes * clarify docs * use first stream as default in dialog * ensure still image is visible after switching stream type to none * docs * clarify docs * add ability to continue playing stream in background * fix props * put stream selection inside dropdown on desktop * add capabilities to live mode hook * live context menu component * resize observer: only return new dimensions if they've actually changed * pass volume prop to players * fix slider bug, https://github.com/shadcn-ui/ui/issues/1448 * update react-grid-layout * prevent animated transitions on draggable grid layout * add context menu to dashboards * use provider * streaming dialog from context menu * docs * add jsmpeg warning to context menu * audio and two way talk indicators in single camera view * add link to debug view * don't use hook * create manual events from live camera view * maintain grow classes on grid items * fix initial volume state on default dashboard * fix pointer events causing context menu to end up underneath image on iOS * mobile drawer tweaks * stream stats * show settings menu for non-restreamed cameras * consistent settings icon * tweaks * optional stats to fix birdseye player * add toaster to live camera view * fix crash on initial save in streaming dialog * don't require restreaming for context menu streaming settings * add debug view to context menu * stats fixes * update docs * always show stream info when restreamed * update camera streaming dialog * make note of no h265 support for webrtc * docs clarity * ensure docs show streams as a dict * docs clarity * fix css file * tweaks
421 lines
15 KiB
Python
421 lines
15 KiB
Python
"""configuration utils."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import shutil
|
|
from typing import Optional, Union
|
|
|
|
from ruamel.yaml import YAML
|
|
|
|
from frigate.const import CONFIG_DIR, EXPORT_DIR
|
|
from frigate.util.services import get_video_properties
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CURRENT_CONFIG_VERSION = "0.16-0"
|
|
DEFAULT_CONFIG_FILE = "/config/config.yml"
|
|
|
|
|
|
def find_config_file() -> str:
|
|
config_path = os.environ.get("CONFIG_FILE", DEFAULT_CONFIG_FILE)
|
|
|
|
if not os.path.isfile(config_path):
|
|
config_path = config_path.replace("yml", "yaml")
|
|
|
|
return config_path
|
|
|
|
|
|
def migrate_frigate_config(config_file: str):
|
|
"""handle migrating the frigate config."""
|
|
logger.info("Checking if frigate config needs migration...")
|
|
|
|
if not os.access(config_file, mode=os.W_OK):
|
|
logger.error("Config file is read-only, unable to migrate config file.")
|
|
return
|
|
|
|
yaml = YAML()
|
|
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
with open(config_file, "r") as f:
|
|
config: dict[str, dict[str, any]] = yaml.load(f)
|
|
|
|
if config is None:
|
|
logger.error(f"Failed to load config at {config_file}")
|
|
return
|
|
|
|
previous_version = str(config.get("version", "0.13"))
|
|
|
|
if previous_version == CURRENT_CONFIG_VERSION:
|
|
logger.info("frigate config does not need migration...")
|
|
return
|
|
|
|
logger.info("copying config as backup...")
|
|
shutil.copy(config_file, os.path.join(CONFIG_DIR, "backup_config.yaml"))
|
|
|
|
if previous_version < "0.14":
|
|
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
|
|
new_config = migrate_014(config)
|
|
with open(config_file, "w") as f:
|
|
yaml.dump(new_config, f)
|
|
previous_version = "0.14"
|
|
|
|
logger.info("Migrating export file names...")
|
|
if os.path.isdir(EXPORT_DIR):
|
|
for file in os.listdir(EXPORT_DIR):
|
|
if "@" not in file:
|
|
continue
|
|
|
|
new_name = file.replace("@", "_")
|
|
os.rename(
|
|
os.path.join(EXPORT_DIR, file), os.path.join(EXPORT_DIR, new_name)
|
|
)
|
|
|
|
if previous_version < "0.15-0":
|
|
logger.info(f"Migrating frigate config from {previous_version} to 0.15-0...")
|
|
new_config = migrate_015_0(config)
|
|
with open(config_file, "w") as f:
|
|
yaml.dump(new_config, f)
|
|
previous_version = "0.15-0"
|
|
|
|
if previous_version < "0.15-1":
|
|
logger.info(f"Migrating frigate config from {previous_version} to 0.15-1...")
|
|
new_config = migrate_015_1(config)
|
|
with open(config_file, "w") as f:
|
|
yaml.dump(new_config, f)
|
|
previous_version = "0.15-1"
|
|
|
|
if previous_version < "0.16-0":
|
|
logger.info(f"Migrating frigate config from {previous_version} to 0.16-0...")
|
|
new_config = migrate_016_0(config)
|
|
with open(config_file, "w") as f:
|
|
yaml.dump(new_config, f)
|
|
previous_version = "0.16-0"
|
|
|
|
logger.info("Finished frigate config migration...")
|
|
|
|
|
|
def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
|
"""Handle migrating frigate config to 0.14"""
|
|
# migrate record.events.required_zones to review.alerts.required_zones
|
|
new_config = config.copy()
|
|
global_required_zones = (
|
|
config.get("record", {}).get("events", {}).get("required_zones", [])
|
|
)
|
|
|
|
if global_required_zones:
|
|
# migrate to new review config
|
|
if not new_config.get("review"):
|
|
new_config["review"] = {}
|
|
|
|
if not new_config["review"].get("alerts"):
|
|
new_config["review"]["alerts"] = {}
|
|
|
|
if not new_config["review"]["alerts"].get("required_zones"):
|
|
new_config["review"]["alerts"]["required_zones"] = global_required_zones
|
|
|
|
# remove record required zones config
|
|
del new_config["record"]["events"]["required_zones"]
|
|
|
|
# remove record altogether if there is not other config
|
|
if not new_config["record"]["events"]:
|
|
del new_config["record"]["events"]
|
|
|
|
if not new_config["record"]:
|
|
del new_config["record"]
|
|
|
|
# Remove UI fields
|
|
if new_config.get("ui"):
|
|
if new_config["ui"].get("use_experimental"):
|
|
del new_config["ui"]["use_experimental"]
|
|
|
|
if new_config["ui"].get("live_mode"):
|
|
del new_config["ui"]["live_mode"]
|
|
|
|
if not new_config["ui"]:
|
|
del new_config["ui"]
|
|
|
|
# remove rtmp
|
|
if new_config.get("ffmpeg", {}).get("output_args", {}).get("rtmp"):
|
|
del new_config["ffmpeg"]["output_args"]["rtmp"]
|
|
|
|
if new_config.get("rtmp"):
|
|
del new_config["rtmp"]
|
|
|
|
for name, camera in config.get("cameras", {}).items():
|
|
camera_config: dict[str, dict[str, any]] = camera.copy()
|
|
required_zones = (
|
|
camera_config.get("record", {}).get("events", {}).get("required_zones", [])
|
|
)
|
|
|
|
if required_zones:
|
|
# migrate to new review config
|
|
if not camera_config.get("review"):
|
|
camera_config["review"] = {}
|
|
|
|
if not camera_config["review"].get("alerts"):
|
|
camera_config["review"]["alerts"] = {}
|
|
|
|
if not camera_config["review"]["alerts"].get("required_zones"):
|
|
camera_config["review"]["alerts"]["required_zones"] = required_zones
|
|
|
|
# remove record required zones config
|
|
del camera_config["record"]["events"]["required_zones"]
|
|
|
|
# remove record altogether if there is not other config
|
|
if not camera_config["record"]["events"]:
|
|
del camera_config["record"]["events"]
|
|
|
|
if not camera_config["record"]:
|
|
del camera_config["record"]
|
|
|
|
# remove rtmp
|
|
if camera_config.get("ffmpeg", {}).get("output_args", {}).get("rtmp"):
|
|
del camera_config["ffmpeg"]["output_args"]["rtmp"]
|
|
|
|
if camera_config.get("rtmp"):
|
|
del camera_config["rtmp"]
|
|
|
|
new_config["cameras"][name] = camera_config
|
|
|
|
new_config["version"] = "0.14"
|
|
return new_config
|
|
|
|
|
|
def migrate_015_0(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
|
"""Handle migrating frigate config to 0.15-0"""
|
|
new_config = config.copy()
|
|
|
|
# migrate record.events to record.alerts and record.detections
|
|
global_record_events = config.get("record", {}).get("events")
|
|
if global_record_events:
|
|
alerts_retention = {"retain": {}}
|
|
detections_retention = {"retain": {}}
|
|
|
|
if global_record_events.get("pre_capture"):
|
|
alerts_retention["pre_capture"] = global_record_events["pre_capture"]
|
|
|
|
if global_record_events.get("post_capture"):
|
|
alerts_retention["post_capture"] = global_record_events["post_capture"]
|
|
|
|
if global_record_events.get("retain", {}).get("default"):
|
|
alerts_retention["retain"]["days"] = global_record_events["retain"][
|
|
"default"
|
|
]
|
|
|
|
# decide logical detections retention based on current detections config
|
|
if not config.get("review", {}).get("alerts", {}).get(
|
|
"required_zones"
|
|
) or config.get("review", {}).get("detections"):
|
|
if global_record_events.get("pre_capture"):
|
|
detections_retention["pre_capture"] = global_record_events[
|
|
"pre_capture"
|
|
]
|
|
|
|
if global_record_events.get("post_capture"):
|
|
detections_retention["post_capture"] = global_record_events[
|
|
"post_capture"
|
|
]
|
|
|
|
if global_record_events.get("retain", {}).get("default"):
|
|
detections_retention["retain"]["days"] = global_record_events["retain"][
|
|
"default"
|
|
]
|
|
else:
|
|
continuous_days = config.get("record", {}).get("retain", {}).get("days")
|
|
detections_retention["retain"]["days"] = (
|
|
continuous_days if continuous_days else 1
|
|
)
|
|
|
|
new_config["record"]["alerts"] = alerts_retention
|
|
new_config["record"]["detections"] = detections_retention
|
|
|
|
del new_config["record"]["events"]
|
|
|
|
for name, camera in config.get("cameras", {}).items():
|
|
camera_config: dict[str, dict[str, any]] = camera.copy()
|
|
|
|
record_events: dict[str, any] = camera_config.get("record", {}).get("events")
|
|
|
|
if record_events:
|
|
alerts_retention = {"retain": {}}
|
|
detections_retention = {"retain": {}}
|
|
|
|
if record_events.get("pre_capture"):
|
|
alerts_retention["pre_capture"] = record_events["pre_capture"]
|
|
|
|
if record_events.get("post_capture"):
|
|
alerts_retention["post_capture"] = record_events["post_capture"]
|
|
|
|
if record_events.get("retain", {}).get("default"):
|
|
alerts_retention["retain"]["days"] = record_events["retain"]["default"]
|
|
|
|
# decide logical detections retention based on current detections config
|
|
if not camera_config.get("review", {}).get("alerts", {}).get(
|
|
"required_zones"
|
|
) or camera_config.get("review", {}).get("detections"):
|
|
if record_events.get("pre_capture"):
|
|
detections_retention["pre_capture"] = record_events["pre_capture"]
|
|
|
|
if record_events.get("post_capture"):
|
|
detections_retention["post_capture"] = record_events["post_capture"]
|
|
|
|
if record_events.get("retain", {}).get("default"):
|
|
detections_retention["retain"]["days"] = record_events["retain"][
|
|
"default"
|
|
]
|
|
else:
|
|
continuous_days = (
|
|
camera_config.get("record", {}).get("retain", {}).get("days")
|
|
)
|
|
detections_retention["retain"]["days"] = (
|
|
continuous_days if continuous_days else 1
|
|
)
|
|
|
|
camera_config["record"]["alerts"] = alerts_retention
|
|
camera_config["record"]["detections"] = detections_retention
|
|
del camera_config["record"]["events"]
|
|
|
|
new_config["cameras"][name] = camera_config
|
|
|
|
new_config["version"] = "0.15-0"
|
|
return new_config
|
|
|
|
|
|
def migrate_015_1(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
|
"""Handle migrating frigate config to 0.15-1"""
|
|
new_config = config.copy()
|
|
|
|
for detector, detector_config in config.get("detectors", {}).items():
|
|
path = detector_config.get("model", {}).get("path")
|
|
|
|
if path:
|
|
new_config["detectors"][detector]["model_path"] = path
|
|
del new_config["detectors"][detector]["model"]
|
|
|
|
new_config["version"] = "0.15-1"
|
|
return new_config
|
|
|
|
|
|
def migrate_016_0(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
|
"""Handle migrating frigate config to 0.16-0"""
|
|
new_config = config.copy()
|
|
|
|
for name, camera in config.get("cameras", {}).items():
|
|
camera_config: dict[str, dict[str, any]] = camera.copy()
|
|
|
|
live_config = camera_config.get("live", {})
|
|
if "stream_name" in live_config:
|
|
# Migrate from live -> stream_name to live -> streams -> dict
|
|
stream_name = live_config["stream_name"]
|
|
live_config["streams"] = {stream_name: stream_name}
|
|
|
|
del live_config["stream_name"]
|
|
|
|
camera_config["live"] = live_config
|
|
|
|
new_config["cameras"][name] = camera_config
|
|
|
|
new_config["version"] = "0.16-0"
|
|
return new_config
|
|
|
|
|
|
def get_relative_coordinates(
|
|
mask: Optional[Union[str, list]], frame_shape: tuple[int, int]
|
|
) -> Union[str, list]:
|
|
# masks and zones are saved as relative coordinates
|
|
# we know if any points are > 1 then it is using the
|
|
# old native resolution coordinates
|
|
if mask:
|
|
if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")):
|
|
relative_masks = []
|
|
for m in mask:
|
|
points = m.split(",")
|
|
|
|
if any(x > "1.0" for x in points):
|
|
rel_points = []
|
|
for i in range(0, len(points), 2):
|
|
x = int(points[i])
|
|
y = int(points[i + 1])
|
|
|
|
if x > frame_shape[1] or y > frame_shape[0]:
|
|
logger.error(
|
|
f"Not applying mask due to invalid coordinates. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask."
|
|
)
|
|
continue
|
|
|
|
rel_points.append(
|
|
f"{round(x / frame_shape[1], 3)},{round(y / frame_shape[0], 3)}"
|
|
)
|
|
|
|
relative_masks.append(",".join(rel_points))
|
|
else:
|
|
relative_masks.append(m)
|
|
|
|
mask = relative_masks
|
|
elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")):
|
|
points = mask.split(",")
|
|
rel_points = []
|
|
|
|
for i in range(0, len(points), 2):
|
|
x = int(points[i])
|
|
y = int(points[i + 1])
|
|
|
|
if x > frame_shape[1] or y > frame_shape[0]:
|
|
logger.error(
|
|
f"Not applying mask due to invalid coordinates. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask."
|
|
)
|
|
return []
|
|
|
|
rel_points.append(
|
|
f"{round(x / frame_shape[1], 3)},{round(y / frame_shape[0], 3)}"
|
|
)
|
|
|
|
mask = ",".join(rel_points)
|
|
|
|
return mask
|
|
|
|
return mask
|
|
|
|
|
|
def convert_area_to_pixels(
|
|
area_value: Union[int, float], frame_shape: tuple[int, int]
|
|
) -> int:
|
|
"""
|
|
Convert area specification to pixels.
|
|
|
|
Args:
|
|
area_value: Area value (pixels or percentage)
|
|
frame_shape: Tuple of (height, width) for the frame
|
|
|
|
Returns:
|
|
Area in pixels
|
|
"""
|
|
# If already an integer, assume it's in pixels
|
|
if isinstance(area_value, int):
|
|
return area_value
|
|
|
|
# Check if it's a percentage
|
|
if isinstance(area_value, float):
|
|
if 0.000001 <= area_value <= 0.99:
|
|
frame_area = frame_shape[0] * frame_shape[1]
|
|
return max(1, int(frame_area * area_value))
|
|
else:
|
|
raise ValueError(
|
|
f"Percentage must be between 0.000001 and 0.99, got {area_value}"
|
|
)
|
|
|
|
raise TypeError(f"Unexpected type for area: {type(area_value)}")
|
|
|
|
|
|
class StreamInfoRetriever:
|
|
def __init__(self) -> None:
|
|
self.stream_cache: dict[str, tuple[int, int]] = {}
|
|
|
|
def get_stream_info(self, ffmpeg, path: str) -> str:
|
|
if path in self.stream_cache:
|
|
return self.stream_cache[path]
|
|
|
|
info = asyncio.run(get_video_properties(ffmpeg, path))
|
|
self.stream_cache[path] = info
|
|
return info
|