mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-06 19:25:22 +03:00
Add support for storing the relationship between recordings and events in the RecordingsToEvents table
This commit is contained in:
parent
3921a7faa2
commit
7adea8f3c9
@ -5,6 +5,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@ -23,6 +24,7 @@ from flask import (
|
|||||||
jsonify,
|
jsonify,
|
||||||
make_response,
|
make_response,
|
||||||
request,
|
request,
|
||||||
|
send_from_directory,
|
||||||
)
|
)
|
||||||
from peewee import DoesNotExist, fn, operator
|
from peewee import DoesNotExist, fn, operator
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
@ -38,7 +40,7 @@ from frigate.const import (
|
|||||||
RECORD_DIR,
|
RECORD_DIR,
|
||||||
)
|
)
|
||||||
from frigate.events.external import ExternalEventProcessor
|
from frigate.events.external import ExternalEventProcessor
|
||||||
from frigate.models import Event, Recordings, Timeline
|
from frigate.models import Event, Recordings, RecordingsToEvents, Timeline
|
||||||
from frigate.object_processing import TrackedObject
|
from frigate.object_processing import TrackedObject
|
||||||
from frigate.plus import PlusApi
|
from frigate.plus import PlusApi
|
||||||
from frigate.ptz.onvif import OnvifController
|
from frigate.ptz.onvif import OnvifController
|
||||||
@ -699,6 +701,66 @@ def label_snapshot(camera_name, label):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/events/<id>/record.mp3")
|
||||||
|
def event_audio(id):
|
||||||
|
download = request.args.get("download", type=bool)
|
||||||
|
|
||||||
|
try:
|
||||||
|
event: Event = Event.get(Event.id == id)
|
||||||
|
except DoesNotExist:
|
||||||
|
return "Event not found.", 404
|
||||||
|
|
||||||
|
recordings = (
|
||||||
|
Recordings.select(Recordings.path)
|
||||||
|
.join(RecordingsToEvents, on=(Recordings.id == RecordingsToEvents.recording_id))
|
||||||
|
.where(RecordingsToEvents.event_id == event.id)
|
||||||
|
)
|
||||||
|
# Extract file paths from the query
|
||||||
|
file_paths = [rec.path for rec in recordings]
|
||||||
|
|
||||||
|
# Generate a temporary output file name for the combined MP3
|
||||||
|
output_file = tempfile.NamedTemporaryFile(
|
||||||
|
prefix=id, suffix=".mp3", delete=False
|
||||||
|
).name
|
||||||
|
os.unlink(output_file) # fucking python
|
||||||
|
|
||||||
|
# Create a list of inputs for FFmpeg
|
||||||
|
ffmpeg_inputs = sum([["-i", path] for path in file_paths], [])
|
||||||
|
|
||||||
|
# Use FFmpeg to extract audio from each mp4 and combine into a single MP3
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y", # fucking python #2
|
||||||
|
*ffmpeg_inputs,
|
||||||
|
"-filter_complex",
|
||||||
|
"concat=n={}:v=0:a=1[aout]".format(len(file_paths)),
|
||||||
|
"-map",
|
||||||
|
"[aout]",
|
||||||
|
"-vn",
|
||||||
|
output_file,
|
||||||
|
]
|
||||||
|
logger.debug(f"ffmpeg command for {id}/record.mp3: {cmd}")
|
||||||
|
sp.run(cmd)
|
||||||
|
|
||||||
|
if not os.path.exists(output_file):
|
||||||
|
return "Error processing audio files.", 500
|
||||||
|
|
||||||
|
# Trigger download if requested
|
||||||
|
if download:
|
||||||
|
return send_from_directory(
|
||||||
|
os.path.dirname(output_file),
|
||||||
|
os.path.basename(output_file),
|
||||||
|
as_attachment=True,
|
||||||
|
attachment_filename=f"event-{id}.mp3",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Otherwise, just return the combined file's path or content
|
||||||
|
# Depending on your needs, you can directly stream the audio or just provide the path
|
||||||
|
return send_from_directory(
|
||||||
|
os.path.dirname(output_file), os.path.basename(output_file)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/events/<id>/clip.mp4")
|
@bp.route("/events/<id>/clip.mp4")
|
||||||
def event_clip(id):
|
def event_clip(id):
|
||||||
download = request.args.get("download", type=bool)
|
download = request.args.get("download", type=bool)
|
||||||
@ -1167,6 +1229,8 @@ def latest_frame(camera_name):
|
|||||||
"motion_boxes": request.args.get("motion", type=int),
|
"motion_boxes": request.args.get("motion", type=int),
|
||||||
"regions": request.args.get("regions", type=int),
|
"regions": request.args.get("regions", type=int),
|
||||||
}
|
}
|
||||||
|
# TODO: debug print draw_options
|
||||||
|
logger.debug(f"Drawing options for {camera_name}: {draw_options}")
|
||||||
resize_quality = request.args.get("quality", default=70, type=int)
|
resize_quality = request.args.get("quality", default=70, type=int)
|
||||||
|
|
||||||
if camera_name in current_app.frigate_config.cameras:
|
if camera_name in current_app.frigate_config.cameras:
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from peewee import (
|
from peewee import (
|
||||||
BooleanField,
|
BooleanField,
|
||||||
CharField,
|
CharField,
|
||||||
|
CompositeKey,
|
||||||
DateTimeField,
|
DateTimeField,
|
||||||
FloatField,
|
FloatField,
|
||||||
IntegerField,
|
IntegerField,
|
||||||
@ -70,6 +71,15 @@ class Recordings(Model): # type: ignore[misc]
|
|||||||
segment_size = FloatField(default=0) # this should be stored as MB
|
segment_size = FloatField(default=0) # this should be stored as MB
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingsToEvents(Model): # type: ignore[misc]
|
||||||
|
event_id = CharField(null=False, index=True, max_length=30)
|
||||||
|
recording_id = CharField(null=False, index=True, max_length=30)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "recordingstoevents"
|
||||||
|
primary_key = CompositeKey("event", "recording")
|
||||||
|
|
||||||
|
|
||||||
# Used for temporary table in record/cleanup.py
|
# Used for temporary table in record/cleanup.py
|
||||||
class RecordingsToDelete(Model): # type: ignore[misc]
|
class RecordingsToDelete(Model): # type: ignore[misc]
|
||||||
id = CharField(null=False, primary_key=False, max_length=30)
|
id = CharField(null=False, primary_key=False, max_length=30)
|
||||||
|
|||||||
@ -66,6 +66,14 @@ class RecordingCleanup(threading.Thread):
|
|||||||
Recordings.delete().where(
|
Recordings.delete().where(
|
||||||
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
||||||
).execute()
|
).execute()
|
||||||
|
"""
|
||||||
|
TODO: right way
|
||||||
|
|
||||||
|
RecordingsToEvents.update(is_deleted=True).where(
|
||||||
|
RecordingsToEvents.recording_id
|
||||||
|
<< deleted_recordings_list[i : i + max_deletes]
|
||||||
|
).execute()
|
||||||
|
"""
|
||||||
logger.debug("End deleted cameras.")
|
logger.debug("End deleted cameras.")
|
||||||
|
|
||||||
logger.debug("Start all cameras.")
|
logger.debug("Start all cameras.")
|
||||||
@ -154,6 +162,14 @@ class RecordingCleanup(threading.Thread):
|
|||||||
Recordings.delete().where(
|
Recordings.delete().where(
|
||||||
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
||||||
).execute()
|
).execute()
|
||||||
|
"""
|
||||||
|
TODO: right way
|
||||||
|
|
||||||
|
RecordingsToEvents.update(is_deleted=True).where(
|
||||||
|
RecordingsToEvents.recording_id
|
||||||
|
<< deleted_recordings_list[i : i + max_deletes]
|
||||||
|
).execute()
|
||||||
|
"""
|
||||||
|
|
||||||
logger.debug(f"End camera: {camera}.")
|
logger.debug(f"End camera: {camera}.")
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@ from frigate.const import (
|
|||||||
MAX_SEGMENT_DURATION,
|
MAX_SEGMENT_DURATION,
|
||||||
RECORD_DIR,
|
RECORD_DIR,
|
||||||
)
|
)
|
||||||
from frigate.models import Event, Recordings
|
from frigate.models import Event, Recordings, RecordingsToEvents
|
||||||
from frigate.types import FeatureMetricsTypes
|
from frigate.types import FeatureMetricsTypes
|
||||||
from frigate.util.image import area
|
from frigate.util.image import area
|
||||||
from frigate.util.services import get_video_properties
|
from frigate.util.services import get_video_properties
|
||||||
@ -173,6 +173,13 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
(INSERT_MANY_RECORDINGS, [r for r in recordings_to_insert if r is not None])
|
(INSERT_MANY_RECORDINGS, [r for r in recordings_to_insert if r is not None])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def store_recording_to_event_relation(
|
||||||
|
self, recording_id: str, event_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Store the relationship between a recording and an event in the RecordingsToEvents table."""
|
||||||
|
relation = RecordingsToEvents(recording=recording_id, event=event_id)
|
||||||
|
relation.save()
|
||||||
|
|
||||||
async def validate_and_move_segment(
|
async def validate_and_move_segment(
|
||||||
self, camera: str, events: Event, recording: dict[str, any]
|
self, camera: str, events: Event, recording: dict[str, any]
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -221,6 +228,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
):
|
):
|
||||||
# if the cached segment overlaps with the events:
|
# if the cached segment overlaps with the events:
|
||||||
overlaps = False
|
overlaps = False
|
||||||
|
overlapping_event_id = None
|
||||||
for event in events:
|
for event in events:
|
||||||
# if the event starts in the future, stop checking events
|
# if the event starts in the future, stop checking events
|
||||||
# and remove this segment
|
# and remove this segment
|
||||||
@ -234,12 +242,13 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
# and stop looking at events
|
# and stop looking at events
|
||||||
if event.end_time is None or event.end_time >= start_time.timestamp():
|
if event.end_time is None or event.end_time >= start_time.timestamp():
|
||||||
overlaps = True
|
overlaps = True
|
||||||
|
overlapping_event_id = event.id
|
||||||
break
|
break
|
||||||
|
|
||||||
if overlaps:
|
if overlaps:
|
||||||
record_mode = self.config.cameras[camera].record.events.retain.mode
|
record_mode = self.config.cameras[camera].record.events.retain.mode
|
||||||
# move from cache to recordings immediately
|
# move from cache to recordings immediately
|
||||||
return await self.move_segment(
|
recording_result = await self.move_segment(
|
||||||
camera,
|
camera,
|
||||||
start_time,
|
start_time,
|
||||||
end_time,
|
end_time,
|
||||||
@ -247,6 +256,24 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
cache_path,
|
cache_path,
|
||||||
record_mode,
|
record_mode,
|
||||||
)
|
)
|
||||||
|
if recording_result:
|
||||||
|
try:
|
||||||
|
# Store the relation in the RecordingsToEvents table
|
||||||
|
self.store_recording_to_event_relation(
|
||||||
|
recording_result.id, overlapping_event_id
|
||||||
|
)
|
||||||
|
logging.debug(
|
||||||
|
f"Successfully stored relation for recording_id: {recording_result}, event_id: {overlapping_event_id} in RecordingsToEvents table"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to store relation r:{recording_result},e:{overlapping_event_id} in RecordingsToEvents table: {str(e)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.debug(
|
||||||
|
f"No recording result available for overlapping event_id {overlapping_event_id}"
|
||||||
|
)
|
||||||
|
return recording_result
|
||||||
# if it doesn't overlap with an event, go ahead and drop the segment
|
# if it doesn't overlap with an event, go ahead and drop the segment
|
||||||
# if it ends more than the configured pre_capture for the camera
|
# if it ends more than the configured pre_capture for the camera
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -173,6 +173,14 @@ class StorageMaintainer(threading.Thread):
|
|||||||
Recordings.delete().where(
|
Recordings.delete().where(
|
||||||
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
||||||
).execute()
|
).execute()
|
||||||
|
"""
|
||||||
|
TODO: right way
|
||||||
|
|
||||||
|
RecordingsToEvents.update(is_deleted=True).where(
|
||||||
|
RecordingsToEvents.recording_id
|
||||||
|
<< deleted_recordings_list[i : i + max_deletes]
|
||||||
|
).execute()
|
||||||
|
"""
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Check every 5 minutes if storage needs to be cleaned up."""
|
"""Check every 5 minutes if storage needs to be cleaned up."""
|
||||||
|
|||||||
37
migrations/019_recordings_to_events.py
Normal file
37
migrations/019_recordings_to_events.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# migrations/019_recordings_to_events.py
|
||||||
|
from peewee import CharField, CompositeKey, Model
|
||||||
|
|
||||||
|
from frigate.models import Recordings, Event
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(migrator, database, fake=False, **kwargs):
|
||||||
|
"""Write your migrations here."""
|
||||||
|
|
||||||
|
@migrator.create_model
|
||||||
|
class RecordingsToEvents(Model): # type: ignore[misc]
|
||||||
|
event_id = CharField(null=False, index=True, max_length=30)
|
||||||
|
recording_id = CharField(null=False, index=True, max_length=30)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "recordingstoevents"
|
||||||
|
primary_key = CompositeKey("event_id", "recording_id")
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
INSERT INTO recordingstoevents (recording_id, event_id)
|
||||||
|
SELECT
|
||||||
|
r.id AS recording,
|
||||||
|
e.id AS event
|
||||||
|
FROM
|
||||||
|
event e
|
||||||
|
JOIN
|
||||||
|
recordings r ON e.camera = r.camera
|
||||||
|
WHERE
|
||||||
|
r.start_time <= e.end_time
|
||||||
|
AND r.end_time >= e.start_time;
|
||||||
|
"""
|
||||||
|
migrator.sql(sql)
|
||||||
|
|
||||||
|
|
||||||
|
def rollback(migrator, database, fake=False, **kwargs):
|
||||||
|
"""This function is used to undo the migration, i.e., to drop the RecordingToEvent table."""
|
||||||
|
migrator.drop_table("recordingstoevents")
|
||||||
Loading…
Reference in New Issue
Block a user