From fe4fb645d38b83ad6e129f74992641eac2b5b1fe Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 19 Apr 2024 16:11:41 -0600 Subject: [PATCH] Save exports to database (#11040) * Save review thumbs in dedicated folder * Create exports table * Save exports to DB and save thumbnail for export * Save full frame always * Fix rounded corners * Save exports that are in progress * No need to remove spaces * Reorganize apis to use IDs * Use new apis for frontend * Get video playback working * Fix deleting and renaming * Import existing exports to DB * Implement downloading * Formatting --- frigate/api/app.py | 2 + frigate/api/export.py | 157 +++++++++++++ frigate/api/media.py | 222 +++--------------- frigate/app.py | 15 ++ frigate/models.py | 10 + frigate/record/export.py | 199 +++++++++++++++- frigate/review/maintainer.py | 8 +- migrations/024_create_export_table.py | 37 +++ web/src/App.tsx | 4 +- web/src/components/card/AnimatedEventCard.tsx | 2 +- web/src/components/card/ExportCard.tsx | 124 +++++----- web/src/pages/{Export.tsx => Exports.tsx} | 79 +++++-- web/src/types/export.ts | 9 + 13 files changed, 584 insertions(+), 284 deletions(-) create mode 100644 frigate/api/export.py create mode 100644 migrations/024_create_export_table.py rename web/src/pages/{Export.tsx => Exports.tsx} (59%) create mode 100644 web/src/types/export.ts diff --git a/frigate/api/app.py b/frigate/api/app.py index b56c9c229..5d0bce78b 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -15,6 +15,7 @@ from peewee import operator from playhouse.sqliteq import SqliteQueueDatabase from frigate.api.event import EventBp +from frigate.api.export import ExportBp from frigate.api.media import MediaBp from frigate.api.preview import PreviewBp from frigate.api.review import ReviewBp @@ -39,6 +40,7 @@ logger = logging.getLogger(__name__) bp = Blueprint("frigate", __name__) bp.register_blueprint(EventBp) +bp.register_blueprint(ExportBp) bp.register_blueprint(MediaBp) bp.register_blueprint(PreviewBp) bp.register_blueprint(ReviewBp) diff --git a/frigate/api/export.py b/frigate/api/export.py new file mode 100644 index 000000000..c88e0146a --- /dev/null +++ b/frigate/api/export.py @@ -0,0 +1,157 @@ +"""Export apis.""" + +import logging +from pathlib import Path +from typing import Optional + +from flask import ( + Blueprint, + current_app, + jsonify, + make_response, + request, +) +from peewee import DoesNotExist +from werkzeug.utils import secure_filename + +from frigate.models import Export, Recordings +from frigate.record.export import PlaybackFactorEnum, RecordingExporter + +logger = logging.getLogger(__name__) + +ExportBp = Blueprint("exports", __name__) + + +@ExportBp.route("/exports") +def get_exports(): + exports = Export.select().order_by(Export.date.desc()).dicts().iterator() + return jsonify([e for e in exports]) + + +@ExportBp.route( + "/export//start//end/", methods=["POST"] +) +@ExportBp.route( + "/export//start//end/", + methods=["POST"], +) +def export_recording(camera_name: str, start_time, end_time): + if not camera_name or not current_app.frigate_config.cameras.get(camera_name): + return make_response( + jsonify( + {"success": False, "message": f"{camera_name} is not a valid camera."} + ), + 404, + ) + + json: dict[str, any] = request.get_json(silent=True) or {} + playback_factor = json.get("playback", "realtime") + name: Optional[str] = json.get("name") + + if len(name or "") > 256: + return make_response( + jsonify({"success": False, "message": "File name is too long."}), + 401, + ) + + recordings_count = ( + Recordings.select() + .where( + Recordings.start_time.between(start_time, end_time) + | Recordings.end_time.between(start_time, end_time) + | ((start_time > Recordings.start_time) & (end_time < Recordings.end_time)) + ) + .where(Recordings.camera == camera_name) + .count() + ) + + if recordings_count <= 0: + return make_response( + jsonify( + {"success": False, "message": "No recordings found for time range"} + ), + 400, + ) + + exporter = RecordingExporter( + current_app.frigate_config, + camera_name, + secure_filename(name) if name else None, + int(start_time), + int(end_time), + ( + PlaybackFactorEnum[playback_factor] + if playback_factor in PlaybackFactorEnum.__members__.values() + else PlaybackFactorEnum.realtime + ), + ) + exporter.start() + return make_response( + jsonify( + { + "success": True, + "message": "Starting export of recording.", + } + ), + 200, + ) + + +@ExportBp.route("/export//", methods=["PATCH"]) +def export_rename(id, new_name: str): + try: + export: Export = Export.get(Export.id == id) + except DoesNotExist: + return make_response( + jsonify( + { + "success": False, + "message": "Export not found.", + } + ), + 404, + ) + + export.name = new_name + export.save() + return make_response( + jsonify( + { + "success": True, + "message": "Successfully renamed export.", + } + ), + 200, + ) + + +@ExportBp.route("/export/", methods=["DELETE"]) +def export_delete(id: str): + try: + export: Export = Export.get(Export.id == id) + except DoesNotExist: + return make_response( + jsonify( + { + "success": False, + "message": "Export not found.", + } + ), + 404, + ) + + Path(export.video_path).unlink(missing_ok=True) + + if export.thumb_path: + Path(export.thumb_path).unlink(missing_ok=True) + + export.delete_instance() + return make_response( + jsonify( + { + "success": True, + "message": "Successfully deleted export.", + } + ), + 200, + ) diff --git a/frigate/api/media.py b/frigate/api/media.py index 5387b2866..9770de157 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -4,11 +4,9 @@ import base64 import glob import logging import os -import re import subprocess as sp import time from datetime import datetime, timedelta, timezone -from typing import Optional from urllib.parse import unquote import cv2 @@ -22,13 +20,11 @@ from werkzeug.utils import secure_filename from frigate.const import ( CACHE_DIR, CLIPS_DIR, - EXPORT_DIR, MAX_SEGMENT_DURATION, PREVIEW_FRAME_TYPE, RECORD_DIR, ) from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment -from frigate.record.export import PlaybackFactorEnum, RecordingExporter from frigate.util.builtin import get_tz_modifiers logger = logging.getLogger(__name__) @@ -592,151 +588,6 @@ def vod_event(id): ) -@MediaBp.route( - "/export//start//end/", methods=["POST"] -) -@MediaBp.route( - "/export//start//end/", - methods=["POST"], -) -def export_recording(camera_name: str, start_time, end_time): - if not camera_name or not current_app.frigate_config.cameras.get(camera_name): - return make_response( - jsonify( - {"success": False, "message": f"{camera_name} is not a valid camera."} - ), - 404, - ) - - json: dict[str, any] = request.get_json(silent=True) or {} - playback_factor = json.get("playback", "realtime") - name: Optional[str] = json.get("name") - - recordings_count = ( - Recordings.select() - .where( - Recordings.start_time.between(start_time, end_time) - | Recordings.end_time.between(start_time, end_time) - | ((start_time > Recordings.start_time) & (end_time < Recordings.end_time)) - ) - .where(Recordings.camera == camera_name) - .count() - ) - - if recordings_count <= 0: - return make_response( - jsonify( - {"success": False, "message": "No recordings found for time range"} - ), - 400, - ) - - exporter = RecordingExporter( - current_app.frigate_config, - camera_name, - secure_filename(name.replace(" ", "_")) if name else None, - int(start_time), - int(end_time), - ( - PlaybackFactorEnum[playback_factor] - if playback_factor in PlaybackFactorEnum.__members__.values() - else PlaybackFactorEnum.realtime - ), - ) - exporter.start() - return make_response( - jsonify( - { - "success": True, - "message": "Starting export of recording.", - } - ), - 200, - ) - - -def export_filename_check_extension(filename: str): - if filename.endswith(".mp4"): - return filename - else: - return filename + ".mp4" - - -def export_filename_is_valid(filename: str): - if re.search(r"[^:_A-Za-z0-9]", filename) or filename.startswith("in_progress."): - return False - else: - return True - - -@MediaBp.route("/export//", methods=["PATCH"]) -def export_rename(file_name_current, file_name_new: str): - safe_file_name_current = secure_filename( - export_filename_check_extension(file_name_current) - ) - file_current = os.path.join(EXPORT_DIR, safe_file_name_current) - - if not os.path.exists(file_current): - return make_response( - jsonify({"success": False, "message": f"{file_name_current} not found."}), - 404, - ) - - if not export_filename_is_valid(file_name_new): - return make_response( - jsonify( - { - "success": False, - "message": f"{file_name_new} contains illegal characters.", - } - ), - 400, - ) - - safe_file_name_new = secure_filename(export_filename_check_extension(file_name_new)) - file_new = os.path.join(EXPORT_DIR, safe_file_name_new) - - if os.path.exists(file_new): - return make_response( - jsonify({"success": False, "message": f"{file_name_new} already exists."}), - 400, - ) - - os.rename(file_current, file_new) - return make_response( - jsonify( - { - "success": True, - "message": "Successfully renamed file.", - } - ), - 200, - ) - - -@MediaBp.route("/export/", methods=["DELETE"]) -def export_delete(file_name: str): - safe_file_name = secure_filename(export_filename_check_extension(file_name)) - file = os.path.join(EXPORT_DIR, safe_file_name) - - if not os.path.exists(file): - return make_response( - jsonify({"success": False, "message": f"{file_name} not found."}), - 404, - ) - - os.unlink(file) - return make_response( - jsonify( - { - "success": True, - "message": "Successfully deleted file.", - } - ), - 200, - ) - - @MediaBp.route("//