From 7adea8f3c9175042b962cfa98652088f11b8f634 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Fri, 11 Aug 2023 00:54:18 +0300 Subject: [PATCH 1/3] Add support for storing the relationship between recordings and events in the `RecordingsToEvents` table --- frigate/http.py | 66 +++++++++++++++++++++++++- frigate/models.py | 10 ++++ frigate/record/cleanup.py | 16 +++++++ frigate/record/maintainer.py | 31 +++++++++++- frigate/storage.py | 8 ++++ migrations/019_recordings_to_events.py | 37 +++++++++++++++ 6 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 migrations/019_recordings_to_events.py diff --git a/frigate/http.py b/frigate/http.py index 1cce6968e3..c156f965a8 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -5,6 +5,7 @@ import logging import os import subprocess as sp +import tempfile import time import traceback from datetime import datetime, timedelta, timezone @@ -23,6 +24,7 @@ jsonify, make_response, request, + send_from_directory, ) from peewee import DoesNotExist, fn, operator from playhouse.shortcuts import model_to_dict @@ -38,7 +40,7 @@ RECORD_DIR, ) 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.plus import PlusApi from frigate.ptz.onvif import OnvifController @@ -699,6 +701,66 @@ def label_snapshot(camera_name, label): return response +@bp.route("/events//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//clip.mp4") def event_clip(id): download = request.args.get("download", type=bool) @@ -1167,6 +1229,8 @@ def latest_frame(camera_name): "motion_boxes": request.args.get("motion", 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) if camera_name in current_app.frigate_config.cameras: diff --git a/frigate/models.py b/frigate/models.py index b29ae91dcb..3ebd6ce639 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -1,6 +1,7 @@ from peewee import ( BooleanField, CharField, + CompositeKey, DateTimeField, FloatField, IntegerField, @@ -70,6 +71,15 @@ class Recordings(Model): # type: ignore[misc] 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 class RecordingsToDelete(Model): # type: ignore[misc] id = CharField(null=False, primary_key=False, max_length=30) diff --git a/frigate/record/cleanup.py b/frigate/record/cleanup.py index cb312b6fd5..021570a5d0 100644 --- a/frigate/record/cleanup.py +++ b/frigate/record/cleanup.py @@ -66,6 +66,14 @@ def expire_recordings(self) -> None: Recordings.delete().where( Recordings.id << deleted_recordings_list[i : i + max_deletes] ).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("Start all cameras.") @@ -154,6 +162,14 @@ def expire_recordings(self) -> None: Recordings.delete().where( Recordings.id << deleted_recordings_list[i : i + max_deletes] ).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}.") diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index c67f07c801..a2a2f42d67 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -24,7 +24,7 @@ MAX_SEGMENT_DURATION, RECORD_DIR, ) -from frigate.models import Event, Recordings +from frigate.models import Event, Recordings, RecordingsToEvents from frigate.types import FeatureMetricsTypes from frigate.util.image import area from frigate.util.services import get_video_properties @@ -173,6 +173,13 @@ async def move_files(self) -> 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( self, camera: str, events: Event, recording: dict[str, any] ) -> None: @@ -221,6 +228,7 @@ async def validate_and_move_segment( ): # if the cached segment overlaps with the events: overlaps = False + overlapping_event_id = None for event in events: # if the event starts in the future, stop checking events # and remove this segment @@ -234,12 +242,13 @@ async def validate_and_move_segment( # and stop looking at events if event.end_time is None or event.end_time >= start_time.timestamp(): overlaps = True + overlapping_event_id = event.id break if overlaps: record_mode = self.config.cameras[camera].record.events.retain.mode # move from cache to recordings immediately - return await self.move_segment( + recording_result = await self.move_segment( camera, start_time, end_time, @@ -247,6 +256,24 @@ async def validate_and_move_segment( cache_path, 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 ends more than the configured pre_capture for the camera else: diff --git a/frigate/storage.py b/frigate/storage.py index 640559b142..017d04d1f5 100644 --- a/frigate/storage.py +++ b/frigate/storage.py @@ -173,6 +173,14 @@ def reduce_storage_consumption(self) -> None: Recordings.delete().where( Recordings.id << deleted_recordings_list[i : i + max_deletes] ).execute() + """ + TODO: right way + + RecordingsToEvents.update(is_deleted=True).where( + RecordingsToEvents.recording_id + << deleted_recordings_list[i : i + max_deletes] + ).execute() + """ def run(self): """Check every 5 minutes if storage needs to be cleaned up.""" diff --git a/migrations/019_recordings_to_events.py b/migrations/019_recordings_to_events.py new file mode 100644 index 0000000000..5fba92ffd7 --- /dev/null +++ b/migrations/019_recordings_to_events.py @@ -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") From 2cca58b8bea3cb8b8a3534bffb053c1f4cef6b3a Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Fri, 11 Aug 2023 22:24:04 +0300 Subject: [PATCH 2/3] Reorder imports in 019_recordings_to_events.py --- migrations/019_recordings_to_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/019_recordings_to_events.py b/migrations/019_recordings_to_events.py index 5fba92ffd7..a2d746bdfe 100644 --- a/migrations/019_recordings_to_events.py +++ b/migrations/019_recordings_to_events.py @@ -1,7 +1,7 @@ # migrations/019_recordings_to_events.py from peewee import CharField, CompositeKey, Model -from frigate.models import Recordings, Event +from frigate.models import Event, Recordings def migrate(migrator, database, fake=False, **kwargs): From d7cce6855a3ed4b55f990257e3f9124392effc37 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Fri, 11 Aug 2023 22:26:29 +0300 Subject: [PATCH 3/3] Refactor migrations/019_recordings_to_events.py to remove unused imports --- migrations/019_recordings_to_events.py | 1 - 1 file changed, 1 deletion(-) diff --git a/migrations/019_recordings_to_events.py b/migrations/019_recordings_to_events.py index 5fba92ffd7..6614d6d0a1 100644 --- a/migrations/019_recordings_to_events.py +++ b/migrations/019_recordings_to_events.py @@ -1,7 +1,6 @@ # 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):