mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	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
This commit is contained in:
		
							parent
							
								
									3b0f9988df
								
							
						
					
					
						commit
						fe4fb645d3
					
				| @ -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) | ||||
|  | ||||
							
								
								
									
										157
									
								
								frigate/api/export.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								frigate/api/export.py
									
									
									
									
									
										Normal file
									
								
							| @ -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/<camera_name>/start/<int:start_time>/end/<int:end_time>", methods=["POST"] | ||||
| ) | ||||
| @ExportBp.route( | ||||
|     "/export/<camera_name>/start/<float:start_time>/end/<float:end_time>", | ||||
|     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/<id>/<new_name>", 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/<id>", 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, | ||||
|     ) | ||||
| @ -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/<camera_name>/start/<int:start_time>/end/<int:end_time>", methods=["POST"] | ||||
| ) | ||||
| @MediaBp.route( | ||||
|     "/export/<camera_name>/start/<float:start_time>/end/<float:end_time>", | ||||
|     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/<file_name_current>/<file_name_new>", 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/<file_name>", 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("/<camera_name>/<label>/snapshot.jpg") | ||||
| def label_snapshot(camera_name, label): | ||||
|     label = unquote(label) | ||||
| @ -1315,26 +1166,32 @@ def preview_gif(camera_name: str, start_ts, end_ts, max_cache_age=2592000): | ||||
| 
 | ||||
| @MediaBp.route("/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/preview.mp4") | ||||
| @MediaBp.route("/<camera_name>/start/<float:start_ts>/end/<float:end_ts>/preview.mp4") | ||||
| def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000): | ||||
| def preview_mp4(camera_name: str, start_ts, end_ts): | ||||
|     file_name = secure_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4") | ||||
|     path = os.path.join(CACHE_DIR, file_name) | ||||
| 
 | ||||
|     if datetime.fromtimestamp(start_ts) < datetime.now().replace(minute=0, second=0): | ||||
|         # has preview mp4 | ||||
|         preview: Previews = ( | ||||
|             Previews.select( | ||||
|                 Previews.camera, | ||||
|                 Previews.path, | ||||
|                 Previews.duration, | ||||
|                 Previews.start_time, | ||||
|                 Previews.end_time, | ||||
|         try: | ||||
|             preview: Previews = ( | ||||
|                 Previews.select( | ||||
|                     Previews.camera, | ||||
|                     Previews.path, | ||||
|                     Previews.duration, | ||||
|                     Previews.start_time, | ||||
|                     Previews.end_time, | ||||
|                 ) | ||||
|                 .where( | ||||
|                     Previews.start_time.between(start_ts, end_ts) | ||||
|                     | Previews.end_time.between(start_ts, end_ts) | ||||
|                     | ((start_ts > Previews.start_time) & (end_ts < Previews.end_time)) | ||||
|                 ) | ||||
|                 .where(Previews.camera == camera_name) | ||||
|                 .limit(1) | ||||
|                 .get() | ||||
|             ) | ||||
|             .where( | ||||
|                 Previews.start_time.between(start_ts, end_ts) | ||||
|                 | Previews.end_time.between(start_ts, end_ts) | ||||
|                 | ((start_ts > Previews.start_time) & (end_ts < Previews.end_time)) | ||||
|             ) | ||||
|             .where(Previews.camera == camera_name) | ||||
|             .limit(1) | ||||
|             .get() | ||||
|         ) | ||||
|         except DoesNotExist: | ||||
|             preview = None | ||||
| 
 | ||||
|         if not preview: | ||||
|             return make_response( | ||||
| @ -1349,6 +1206,7 @@ def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000): | ||||
|             "-hide_banner", | ||||
|             "-loglevel", | ||||
|             "warning", | ||||
|             "-y", | ||||
|             "-ss", | ||||
|             f"00:{minutes}:{seconds}", | ||||
|             "-t", | ||||
| @ -1359,13 +1217,11 @@ def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000): | ||||
|             "8", | ||||
|             "-vf", | ||||
|             "setpts=0.12*PTS", | ||||
|             "-loop", | ||||
|             "0", | ||||
|             "-c:v", | ||||
|             "copy", | ||||
|             "-f", | ||||
|             "mp4", | ||||
|             "-", | ||||
|             "libx264", | ||||
|             "-movflags", | ||||
|             "+faststart", | ||||
|             path, | ||||
|         ] | ||||
| 
 | ||||
|         process = sp.run( | ||||
| @ -1380,7 +1236,6 @@ def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000): | ||||
|                 500, | ||||
|             ) | ||||
| 
 | ||||
|         gif_bytes = process.stdout | ||||
|     else: | ||||
|         # need to generate from existing images | ||||
|         preview_dir = os.path.join(CACHE_DIR, "preview_frames") | ||||
| @ -1424,13 +1279,11 @@ def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000): | ||||
|             "0", | ||||
|             "-i", | ||||
|             "/dev/stdin", | ||||
|             "-loop", | ||||
|             "0", | ||||
|             "-c:v", | ||||
|             "libx264", | ||||
|             "-f", | ||||
|             "gif", | ||||
|             "-", | ||||
|             "-movflags", | ||||
|             "+faststart", | ||||
|             path, | ||||
|         ] | ||||
| 
 | ||||
|         process = sp.run( | ||||
| @ -1446,11 +1299,14 @@ def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000): | ||||
|                 500, | ||||
|             ) | ||||
| 
 | ||||
|         gif_bytes = process.stdout | ||||
| 
 | ||||
|     response = make_response(gif_bytes) | ||||
|     response.headers["Content-Type"] = "image/gif" | ||||
|     response.headers["Cache-Control"] = f"private, max-age={max_cache_age}" | ||||
|     response = make_response() | ||||
|     response.headers["Content-Description"] = "File Transfer" | ||||
|     response.headers["Cache-Control"] = "no-cache" | ||||
|     response.headers["Content-Type"] = "video/mp4" | ||||
|     response.headers["Content-Length"] = os.path.getsize(path) | ||||
|     response.headers["X-Accel-Redirect"] = ( | ||||
|         f"/cache/{file_name}"  # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers | ||||
|     ) | ||||
|     return response | ||||
| 
 | ||||
| 
 | ||||
|  | ||||
| @ -41,6 +41,7 @@ from frigate.events.maintainer import EventProcessor | ||||
| from frigate.log import log_process, root_configurer | ||||
| from frigate.models import ( | ||||
|     Event, | ||||
|     Export, | ||||
|     Previews, | ||||
|     Recordings, | ||||
|     RecordingsToDelete, | ||||
| @ -55,6 +56,7 @@ from frigate.plus import PlusApi | ||||
| from frigate.ptz.autotrack import PtzAutoTrackerThread | ||||
| from frigate.ptz.onvif import OnvifController | ||||
| from frigate.record.cleanup import RecordingCleanup | ||||
| from frigate.record.export import migrate_exports | ||||
| from frigate.record.record import manage_recordings | ||||
| from frigate.review.review import manage_review_segments | ||||
| from frigate.stats.emitter import StatsEmitter | ||||
| @ -320,6 +322,7 @@ class FrigateApp: | ||||
|         ) | ||||
|         models = [ | ||||
|             Event, | ||||
|             Export, | ||||
|             Previews, | ||||
|             Recordings, | ||||
|             RecordingsToDelete, | ||||
| @ -329,6 +332,17 @@ class FrigateApp: | ||||
|         ] | ||||
|         self.db.bind(models) | ||||
| 
 | ||||
|     def check_db_data_migrations(self) -> None: | ||||
|         # check if vacuum needs to be run | ||||
|         if not os.path.exists(f"{CONFIG_DIR}/.exports"): | ||||
|             try: | ||||
|                 with open(f"{CONFIG_DIR}/.exports", "w") as f: | ||||
|                     f.write(str(datetime.datetime.now().timestamp())) | ||||
|             except PermissionError: | ||||
|                 logger.error("Unable to write to /config to save export state") | ||||
| 
 | ||||
|             migrate_exports(self.config.cameras.keys()) | ||||
| 
 | ||||
|     def init_external_event_processor(self) -> None: | ||||
|         self.external_event_processor = ExternalEventProcessor(self.config) | ||||
| 
 | ||||
| @ -629,6 +643,7 @@ class FrigateApp: | ||||
|             self.init_review_segment_manager() | ||||
|             self.init_go2rtc() | ||||
|             self.bind_database() | ||||
|             self.check_db_data_migrations() | ||||
|             self.init_inter_process_communicator() | ||||
|             self.init_dispatcher() | ||||
|         except Exception as e: | ||||
|  | ||||
| @ -77,6 +77,16 @@ class Recordings(Model):  # type: ignore[misc] | ||||
|     regions = IntegerField(null=True) | ||||
| 
 | ||||
| 
 | ||||
| class Export(Model):  # type: ignore[misc] | ||||
|     id = CharField(null=False, primary_key=True, max_length=30) | ||||
|     camera = CharField(index=True, max_length=20) | ||||
|     name = CharField(index=True, max_length=100) | ||||
|     date = DateTimeField() | ||||
|     video_path = CharField(unique=True) | ||||
|     thumb_path = CharField(unique=True) | ||||
|     in_progress = BooleanField() | ||||
| 
 | ||||
| 
 | ||||
| class ReviewSegment(Model):  # type: ignore[misc] | ||||
|     id = CharField(null=False, primary_key=True, max_length=30) | ||||
|     camera = CharField(index=True, max_length=20) | ||||
|  | ||||
| @ -3,18 +3,27 @@ | ||||
| import datetime | ||||
| import logging | ||||
| import os | ||||
| import random | ||||
| import shutil | ||||
| import string | ||||
| import subprocess as sp | ||||
| import threading | ||||
| from enum import Enum | ||||
| from pathlib import Path | ||||
| 
 | ||||
| from frigate.config import FrigateConfig | ||||
| from frigate.const import EXPORT_DIR, MAX_PLAYLIST_SECONDS | ||||
| from frigate.const import ( | ||||
|     CACHE_DIR, | ||||
|     CLIPS_DIR, | ||||
|     EXPORT_DIR, | ||||
|     MAX_PLAYLIST_SECONDS, | ||||
|     PREVIEW_FRAME_TYPE, | ||||
| ) | ||||
| from frigate.ffmpeg_presets import ( | ||||
|     EncodeTypeEnum, | ||||
|     parse_preset_hardware_acceleration_encode, | ||||
| ) | ||||
| from frigate.models import Recordings | ||||
| from frigate.models import Export, Previews, Recordings | ||||
| 
 | ||||
| logger = logging.getLogger(__name__) | ||||
| 
 | ||||
| @ -51,20 +60,122 @@ class RecordingExporter(threading.Thread): | ||||
|         self.end_time = end_time | ||||
|         self.playback_factor = playback_factor | ||||
| 
 | ||||
|         # ensure export thumb dir | ||||
|         Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True) | ||||
| 
 | ||||
|     def get_datetime_from_timestamp(self, timestamp: int) -> str: | ||||
|         """Convenience fun to get a simple date time from timestamp.""" | ||||
|         return datetime.datetime.fromtimestamp(timestamp).strftime("%Y_%m_%d_%H_%M") | ||||
|         return datetime.datetime.fromtimestamp(timestamp).strftime("%Y/%m/%d %H:%M") | ||||
| 
 | ||||
|     def save_thumbnail(self, id: str) -> str: | ||||
|         thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp") | ||||
| 
 | ||||
|         if datetime.datetime.fromtimestamp( | ||||
|             self.start_time | ||||
|         ) < datetime.datetime.now().replace(minute=0, second=0): | ||||
|             # has preview mp4 | ||||
|             preview: Previews = ( | ||||
|                 Previews.select( | ||||
|                     Previews.camera, | ||||
|                     Previews.path, | ||||
|                     Previews.duration, | ||||
|                     Previews.start_time, | ||||
|                     Previews.end_time, | ||||
|                 ) | ||||
|                 .where( | ||||
|                     Previews.start_time.between(self.start_time, self.end_time) | ||||
|                     | Previews.end_time.between(self.start_time, self.end_time) | ||||
|                     | ( | ||||
|                         (self.start_time > Previews.start_time) | ||||
|                         & (self.end_time < Previews.end_time) | ||||
|                     ) | ||||
|                 ) | ||||
|                 .where(Previews.camera == self.camera) | ||||
|                 .limit(1) | ||||
|                 .get() | ||||
|             ) | ||||
| 
 | ||||
|             if not preview: | ||||
|                 return "" | ||||
| 
 | ||||
|             diff = self.start_time - preview.start_time | ||||
|             minutes = int(diff / 60) | ||||
|             seconds = int(diff % 60) | ||||
|             ffmpeg_cmd = [ | ||||
|                 "ffmpeg", | ||||
|                 "-hide_banner", | ||||
|                 "-loglevel", | ||||
|                 "warning", | ||||
|                 "-ss", | ||||
|                 f"00:{minutes}:{seconds}", | ||||
|                 "-i", | ||||
|                 preview.path, | ||||
|                 "-c:v", | ||||
|                 "libwebp", | ||||
|                 thumb_path, | ||||
|             ] | ||||
| 
 | ||||
|             process = sp.run( | ||||
|                 ffmpeg_cmd, | ||||
|                 capture_output=True, | ||||
|             ) | ||||
| 
 | ||||
|             if process.returncode != 0: | ||||
|                 logger.error(process.stderr) | ||||
|                 return "" | ||||
| 
 | ||||
|         else: | ||||
|             # need to generate from existing images | ||||
|             preview_dir = os.path.join(CACHE_DIR, "preview_frames") | ||||
|             file_start = f"preview_{self.camera}" | ||||
|             start_file = f"{file_start}-{self.start_time}.{PREVIEW_FRAME_TYPE}" | ||||
|             end_file = f"{file_start}-{self.end_time}.{PREVIEW_FRAME_TYPE}" | ||||
|             selected_preview = None | ||||
| 
 | ||||
|             for file in sorted(os.listdir(preview_dir)): | ||||
|                 if not file.startswith(file_start): | ||||
|                     continue | ||||
| 
 | ||||
|                 if file < start_file: | ||||
|                     continue | ||||
| 
 | ||||
|                 if file > end_file: | ||||
|                     break | ||||
| 
 | ||||
|                 selected_preview = os.path.join(preview_dir, file) | ||||
|                 break | ||||
| 
 | ||||
|             if not selected_preview: | ||||
|                 return "" | ||||
| 
 | ||||
|             shutil.copyfile(selected_preview, thumb_path) | ||||
| 
 | ||||
|         return thumb_path | ||||
| 
 | ||||
|     def run(self) -> None: | ||||
|         logger.debug( | ||||
|             f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}" | ||||
|         ) | ||||
|         file_name = ( | ||||
|         export_id = f"{self.camera}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}" | ||||
|         export_name = ( | ||||
|             self.user_provided_name | ||||
|             or f"{self.camera}_{self.get_datetime_from_timestamp(self.start_time)}__{self.get_datetime_from_timestamp(self.end_time)}" | ||||
|             or f"{self.camera.replace('_', ' ')} {self.get_datetime_from_timestamp(self.start_time)} {self.get_datetime_from_timestamp(self.end_time)}" | ||||
|         ) | ||||
|         file_path = f"{EXPORT_DIR}/in_progress.{file_name}.mp4" | ||||
|         final_file_path = f"{EXPORT_DIR}/{file_name}.mp4" | ||||
|         video_path = f"{EXPORT_DIR}/{export_id}.mp4" | ||||
| 
 | ||||
|         thumb_path = self.save_thumbnail(export_id) | ||||
| 
 | ||||
|         Export.insert( | ||||
|             { | ||||
|                 Export.id: export_id, | ||||
|                 Export.camera: self.camera, | ||||
|                 Export.name: export_name, | ||||
|                 Export.date: self.start_time, | ||||
|                 Export.video_path: video_path, | ||||
|                 Export.thumb_path: thumb_path, | ||||
|                 Export.in_progress: True, | ||||
|             } | ||||
|         ).execute() | ||||
| 
 | ||||
|         if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS: | ||||
|             playlist_lines = f"http://127.0.0.1:5000/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8" | ||||
| @ -103,14 +214,14 @@ class RecordingExporter(threading.Thread): | ||||
| 
 | ||||
|         if self.playback_factor == PlaybackFactorEnum.realtime: | ||||
|             ffmpeg_cmd = ( | ||||
|                 f"ffmpeg -hide_banner {ffmpeg_input} -c copy -movflags +faststart {file_path}" | ||||
|                 f"ffmpeg -hide_banner {ffmpeg_input} -c copy -movflags +faststart {video_path}" | ||||
|             ).split(" ") | ||||
|         elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: | ||||
|             ffmpeg_cmd = ( | ||||
|                 parse_preset_hardware_acceleration_encode( | ||||
|                     self.config.ffmpeg.hwaccel_args, | ||||
|                     f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}", | ||||
|                     f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {file_path}", | ||||
|                     f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}", | ||||
|                     EncodeTypeEnum.timelapse, | ||||
|                 ) | ||||
|             ).split(" ") | ||||
| @ -128,9 +239,71 @@ class RecordingExporter(threading.Thread): | ||||
|                 f"Failed to export recording for command {' '.join(ffmpeg_cmd)}" | ||||
|             ) | ||||
|             logger.error(p.stderr) | ||||
|             Path(file_path).unlink(missing_ok=True) | ||||
|             Path(video_path).unlink(missing_ok=True) | ||||
|             Export.delete().where(Export.id == export_id).execute() | ||||
|             Path(thumb_path).unlink(missing_ok=True) | ||||
|             return | ||||
|         else: | ||||
|             Export.update({Export.in_progress: False}).where( | ||||
|                 Export.id == export_id | ||||
|             ).execute() | ||||
| 
 | ||||
|         logger.debug(f"Updating finalized export {file_path}") | ||||
|         os.rename(file_path, final_file_path) | ||||
|         logger.debug(f"Finished exporting {file_path}") | ||||
|         logger.debug(f"Finished exporting {video_path}") | ||||
| 
 | ||||
| 
 | ||||
| def migrate_exports(camera_names: list[str]): | ||||
|     Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True) | ||||
| 
 | ||||
|     exports = [] | ||||
|     for export_file in os.listdir(EXPORT_DIR): | ||||
|         camera = "unknown" | ||||
| 
 | ||||
|         for cam_name in camera_names: | ||||
|             if cam_name in export_file: | ||||
|                 camera = cam_name | ||||
|                 break | ||||
| 
 | ||||
|         id = f"{camera}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}" | ||||
|         video_path = os.path.join(EXPORT_DIR, export_file) | ||||
|         thumb_path = os.path.join( | ||||
|             CLIPS_DIR, f"export/{id}.jpg" | ||||
|         )  # use jpg because webp encoder can't get quality low enough | ||||
| 
 | ||||
|         ffmpeg_cmd = [ | ||||
|             "ffmpeg", | ||||
|             "-hide_banner", | ||||
|             "-loglevel", | ||||
|             "warning", | ||||
|             "-i", | ||||
|             video_path, | ||||
|             "-vf", | ||||
|             "scale=-1:180", | ||||
|             "-frames", | ||||
|             "1", | ||||
|             "-q:v", | ||||
|             "8", | ||||
|             thumb_path, | ||||
|         ] | ||||
| 
 | ||||
|         process = sp.run( | ||||
|             ffmpeg_cmd, | ||||
|             capture_output=True, | ||||
|         ) | ||||
| 
 | ||||
|         if process.returncode != 0: | ||||
|             logger.error(process.stderr) | ||||
|             continue | ||||
| 
 | ||||
|         exports.append( | ||||
|             { | ||||
|                 Export.id: id, | ||||
|                 Export.camera: camera, | ||||
|                 Export.name: export_file.replace(".mp4", ""), | ||||
|                 Export.date: os.path.getctime(video_path), | ||||
|                 Export.video_path: video_path, | ||||
|                 Export.thumb_path: thumb_path, | ||||
|                 Export.in_progress: False, | ||||
|             } | ||||
|         ) | ||||
| 
 | ||||
|     Export.insert_many(exports).execute() | ||||
|  | ||||
| @ -9,6 +9,7 @@ import sys | ||||
| import threading | ||||
| from enum import Enum | ||||
| from multiprocessing.synchronize import Event as MpEvent | ||||
| from pathlib import Path | ||||
| from typing import Optional | ||||
| 
 | ||||
| import cv2 | ||||
| @ -64,7 +65,9 @@ class PendingReviewSegment: | ||||
|         # thumbnail | ||||
|         self.frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8) | ||||
|         self.frame_active_count = 0 | ||||
|         self.frame_path = os.path.join(CLIPS_DIR, f"thumb-{self.camera}-{self.id}.jpg") | ||||
|         self.frame_path = os.path.join( | ||||
|             CLIPS_DIR, f"review/thumb-{self.camera}-{self.id}.webp" | ||||
|         ) | ||||
| 
 | ||||
|     def update_frame( | ||||
|         self, camera_config: CameraConfig, frame, objects: list[TrackedObject] | ||||
| @ -138,6 +141,9 @@ class ReviewSegmentMaintainer(threading.Thread): | ||||
|         # manual events | ||||
|         self.indefinite_events: dict[str, dict[str, any]] = {} | ||||
| 
 | ||||
|         # ensure dirs | ||||
|         Path(os.path.join(CLIPS_DIR, "review")).mkdir(exist_ok=True) | ||||
| 
 | ||||
|         self.stop_event = stop_event | ||||
| 
 | ||||
|     def update_segment(self, segment: PendingReviewSegment) -> None: | ||||
|  | ||||
							
								
								
									
										37
									
								
								migrations/024_create_export_table.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								migrations/024_create_export_table.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,37 @@ | ||||
| """Peewee migrations -- 024_create_export_table.py. | ||||
| 
 | ||||
| Some examples (model - class or model name):: | ||||
| 
 | ||||
|     > Model = migrator.orm['model_name']            # Return model in current state by name | ||||
| 
 | ||||
|     > migrator.sql(sql)                             # Run custom SQL | ||||
|     > migrator.python(func, *args, **kwargs)        # Run python code | ||||
|     > migrator.create_model(Model)                  # Create a model (could be used as decorator) | ||||
|     > migrator.remove_model(model, cascade=True)    # Remove a model | ||||
|     > migrator.add_fields(model, **fields)          # Add fields to a model | ||||
|     > migrator.change_fields(model, **fields)       # Change fields | ||||
|     > migrator.remove_fields(model, *field_names, cascade=True) | ||||
|     > migrator.rename_field(model, old_field_name, new_field_name) | ||||
|     > migrator.rename_table(model, new_table_name) | ||||
|     > migrator.add_index(model, *col_names, unique=False) | ||||
|     > migrator.drop_index(model, *col_names) | ||||
|     > migrator.add_not_null(model, *field_names) | ||||
|     > migrator.drop_not_null(model, *field_names) | ||||
|     > migrator.add_default(model, field_name, default) | ||||
| 
 | ||||
| """ | ||||
| 
 | ||||
| import peewee as pw | ||||
| 
 | ||||
| SQL = pw.SQL | ||||
| 
 | ||||
| 
 | ||||
| def migrate(migrator, database, fake=False, **kwargs): | ||||
|     migrator.sql( | ||||
|         'CREATE TABLE IF NOT EXISTS "export" ("id" VARCHAR(30) NOT NULL PRIMARY KEY, "camera" VARCHAR(20) NOT NULL, "name" VARCHAR(100) NOT NULL, "date" DATETIME NOT NULL, "video_path" VARCHAR(255) NOT NULL, "thumb_path" VARCHAR(255) NOT NULL, "in_progress" INTEGER NOT NULL)' | ||||
|     ) | ||||
|     migrator.sql('CREATE INDEX IF NOT EXISTS "export_camera" ON "export" ("camera")') | ||||
| 
 | ||||
| 
 | ||||
| def rollback(migrator, database, fake=False, **kwargs): | ||||
|     pass | ||||
| @ -11,7 +11,7 @@ import { Redirect } from "./components/navigation/Redirect"; | ||||
| 
 | ||||
| const Live = lazy(() => import("@/pages/Live")); | ||||
| const Events = lazy(() => import("@/pages/Events")); | ||||
| const Export = lazy(() => import("@/pages/Export")); | ||||
| const Exports = lazy(() => import("@/pages/Exports")); | ||||
| const SubmitPlus = lazy(() => import("@/pages/SubmitPlus")); | ||||
| const ConfigEditor = lazy(() => import("@/pages/ConfigEditor")); | ||||
| const System = lazy(() => import("@/pages/System")); | ||||
| @ -38,7 +38,7 @@ function App() { | ||||
|                   <Route path="/" element={<Live />} /> | ||||
|                   <Route path="/events" element={<Redirect to="/review" />} /> | ||||
|                   <Route path="/review" element={<Events />} /> | ||||
|                   <Route path="/export" element={<Export />} /> | ||||
|                   <Route path="/export" element={<Exports />} /> | ||||
|                   <Route path="/plus" element={<SubmitPlus />} /> | ||||
|                   <Route path="/system" element={<System />} /> | ||||
|                   <Route path="/settings" element={<Settings />} /> | ||||
|  | ||||
| @ -63,7 +63,7 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) { | ||||
|           }} | ||||
|         > | ||||
|           <div | ||||
|             className="size-full rounded cursor-pointer" | ||||
|             className="size-full rounded cursor-pointer overflow-hidden" | ||||
|             onClick={onOpenReview} | ||||
|           > | ||||
|             {previews ? ( | ||||
|  | ||||
| @ -1,38 +1,36 @@ | ||||
| import { baseUrl } from "@/api/baseUrl"; | ||||
| import ActivityIndicator from "../indicators/activity-indicator"; | ||||
| import { LuPencil, LuTrash } from "react-icons/lu"; | ||||
| import { LuTrash } from "react-icons/lu"; | ||||
| import { Button } from "../ui/button"; | ||||
| import { useMemo, useRef, useState } from "react"; | ||||
| import { useState } from "react"; | ||||
| import { isDesktop } from "react-device-detect"; | ||||
| import { FaPlay } from "react-icons/fa"; | ||||
| import { FaDownload, FaPlay } from "react-icons/fa"; | ||||
| import Chip from "../indicators/Chip"; | ||||
| import { Skeleton } from "../ui/skeleton"; | ||||
| import { Dialog, DialogContent, DialogFooter, DialogTitle } from "../ui/dialog"; | ||||
| import { Input } from "../ui/input"; | ||||
| import useKeyboardListener from "@/hooks/use-keyboard-listener"; | ||||
| import { Export } from "@/types/export"; | ||||
| import { MdEditSquare } from "react-icons/md"; | ||||
| import { baseUrl } from "@/api/baseUrl"; | ||||
| 
 | ||||
| type ExportProps = { | ||||
|   className: string; | ||||
|   file: { | ||||
|     name: string; | ||||
|   }; | ||||
|   exportedRecording: Export; | ||||
|   onSelect: (selected: Export) => void; | ||||
|   onRename: (original: string, update: string) => void; | ||||
|   onDelete: (file: string) => void; | ||||
| }; | ||||
| 
 | ||||
| export default function ExportCard({ | ||||
|   className, | ||||
|   file, | ||||
|   exportedRecording, | ||||
|   onSelect, | ||||
|   onRename, | ||||
|   onDelete, | ||||
| }: ExportProps) { | ||||
|   const videoRef = useRef<HTMLVideoElement | null>(null); | ||||
|   const [hovered, setHovered] = useState(false); | ||||
|   const [playing, setPlaying] = useState(false); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const inProgress = useMemo( | ||||
|     () => file.name.startsWith("in_progress"), | ||||
|     [file.name], | ||||
|   const [loading, setLoading] = useState( | ||||
|     exportedRecording.thumb_path.length > 0, | ||||
|   ); | ||||
| 
 | ||||
|   // editing name
 | ||||
| @ -46,7 +44,7 @@ export default function ExportCard({ | ||||
|     editName != undefined ? ["Enter"] : [], | ||||
|     (_, down, repeat) => { | ||||
|       if (down && !repeat && editName && editName.update.length > 0) { | ||||
|         onRename(editName.original, editName.update.replaceAll(" ", "_")); | ||||
|         onRename(exportedRecording.id, editName.update); | ||||
|         setEditName(undefined); | ||||
|       } | ||||
|     }, | ||||
| @ -84,10 +82,7 @@ export default function ExportCard({ | ||||
|                   variant="select" | ||||
|                   disabled={(editName?.update?.length ?? 0) == 0} | ||||
|                   onClick={() => { | ||||
|                     onRename( | ||||
|                       editName.original, | ||||
|                       editName.update.replaceAll(" ", "_"), | ||||
|                     ); | ||||
|                     onRename(exportedRecording.id, editName.update); | ||||
|                     setEditName(undefined); | ||||
|                   }} | ||||
|                 > | ||||
| @ -102,75 +97,84 @@ export default function ExportCard({ | ||||
|       <div | ||||
|         className={`relative aspect-video bg-black rounded-2xl flex justify-center items-center ${className}`} | ||||
|         onMouseEnter={ | ||||
|           isDesktop && !inProgress ? () => setHovered(true) : undefined | ||||
|           isDesktop && !exportedRecording.in_progress | ||||
|             ? () => setHovered(true) | ||||
|             : undefined | ||||
|         } | ||||
|         onMouseLeave={ | ||||
|           isDesktop && !inProgress ? () => setHovered(false) : undefined | ||||
|           isDesktop && !exportedRecording.in_progress | ||||
|             ? () => setHovered(false) | ||||
|             : undefined | ||||
|         } | ||||
|         onClick={ | ||||
|           isDesktop || inProgress ? undefined : () => setHovered(!hovered) | ||||
|           isDesktop || exportedRecording.in_progress | ||||
|             ? undefined | ||||
|             : () => setHovered(!hovered) | ||||
|         } | ||||
|       > | ||||
|         {hovered && ( | ||||
|           <> | ||||
|             {!playing && ( | ||||
|               <div className="absolute inset-0 z-10 bg-black bg-opacity-60 rounded-2xl" /> | ||||
|             )} | ||||
|             <div className="absolute inset-0 z-10 bg-black bg-opacity-60 rounded-2xl" /> | ||||
|             <div className="absolute top-1 right-1 flex items-center gap-2"> | ||||
|               <a | ||||
|                 className="z-20" | ||||
|                 download | ||||
|                 href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`} | ||||
|               > | ||||
|                 <Chip className="bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 rounded-md cursor-pointer"> | ||||
|                   <FaDownload className="size-4 text-white" /> | ||||
|                 </Chip> | ||||
|               </a> | ||||
|               <Chip | ||||
|                 className="bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 rounded-md cursor-pointer" | ||||
|                 onClick={() => setEditName({ original: file.name, update: "" })} | ||||
|                 onClick={() => | ||||
|                   setEditName({ original: exportedRecording.name, update: "" }) | ||||
|                 } | ||||
|               > | ||||
|                 <LuPencil className="size-4 text-white" /> | ||||
|                 <MdEditSquare className="size-4 text-white" /> | ||||
|               </Chip> | ||||
|               <Chip | ||||
|                 className="bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 rounded-md cursor-pointer" | ||||
|                 onClick={() => onDelete(file.name)} | ||||
|                 onClick={() => onDelete(exportedRecording.id)} | ||||
|               > | ||||
|                 <LuTrash className="size-4 text-destructive fill-destructive" /> | ||||
|               </Chip> | ||||
|             </div> | ||||
|             {!playing && ( | ||||
|               <Button | ||||
|                 className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 w-20 h-20 z-20 text-white hover:text-white hover:bg-transparent" | ||||
|                 variant="ghost" | ||||
|                 onClick={() => { | ||||
|                   setPlaying(true); | ||||
|                   videoRef.current?.play(); | ||||
|                 }} | ||||
|               > | ||||
|                 <FaPlay /> | ||||
|               </Button> | ||||
|             )} | ||||
| 
 | ||||
|             <Button | ||||
|               className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 w-20 h-20 z-20 text-white hover:text-white hover:bg-transparent cursor-pointer" | ||||
|               variant="ghost" | ||||
|               onClick={() => { | ||||
|                 onSelect(exportedRecording); | ||||
|               }} | ||||
|             > | ||||
|               <FaPlay /> | ||||
|             </Button> | ||||
|           </> | ||||
|         )} | ||||
|         {inProgress ? ( | ||||
|         {exportedRecording.in_progress ? ( | ||||
|           <ActivityIndicator /> | ||||
|         ) : ( | ||||
|           <video | ||||
|             ref={videoRef} | ||||
|             className="absolute inset-0 aspect-video rounded-2xl" | ||||
|             playsInline | ||||
|             preload="auto" | ||||
|             muted | ||||
|             controls={playing} | ||||
|             onLoadedData={() => setLoading(false)} | ||||
|           > | ||||
|             <source src={`${baseUrl}exports/${file.name}`} type="video/mp4" /> | ||||
|           </video> | ||||
|           <> | ||||
|             {exportedRecording.thumb_path.length > 0 ? ( | ||||
|               <img | ||||
|                 className="size-full absolute inset-0 object-contain aspect-video rounded-2xl" | ||||
|                 src={exportedRecording.thumb_path.replace("/media/frigate", "")} | ||||
|                 onLoad={() => setLoading(false)} | ||||
|               /> | ||||
|             ) : ( | ||||
|               <div className="absolute inset-0 bg-secondary rounded-2xl" /> | ||||
|             )} | ||||
|           </> | ||||
|         )} | ||||
|         {loading && ( | ||||
|           <Skeleton className="absolute inset-0 aspect-video rounded-2xl" /> | ||||
|         )} | ||||
|         {!playing && ( | ||||
|           <div className="absolute bottom-0 inset-x-0 rounded-b-l z-10 h-[20%] bg-gradient-to-t from-black/60 to-transparent pointer-events-none rounded-2xl"> | ||||
|             <div className="flex h-full justify-between items-end mx-3 pb-1 text-white text-sm capitalize"> | ||||
|               {file.name | ||||
|                 .substring(0, file.name.length - 4) | ||||
|                 .replaceAll("_", " ")} | ||||
|             </div> | ||||
|         <div className="absolute bottom-0 inset-x-0 rounded-b-l z-10 h-[20%] bg-gradient-to-t from-black/60 to-transparent pointer-events-none rounded-2xl"> | ||||
|           <div className="flex h-full justify-between items-end mx-3 pb-1 text-white text-sm capitalize"> | ||||
|             {exportedRecording.name.replaceAll("_", " ")} | ||||
|           </div> | ||||
|         )} | ||||
|         </div> | ||||
|       </div> | ||||
|     </> | ||||
|   ); | ||||
|  | ||||
| @ -10,20 +10,15 @@ import { | ||||
|   AlertDialogTitle, | ||||
| } from "@/components/ui/alert-dialog"; | ||||
| import { Button } from "@/components/ui/button"; | ||||
| import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; | ||||
| import { Input } from "@/components/ui/input"; | ||||
| import { Export } from "@/types/export"; | ||||
| import axios from "axios"; | ||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | ||||
| import useSWR from "swr"; | ||||
| 
 | ||||
| type ExportItem = { | ||||
|   name: string; | ||||
| }; | ||||
| 
 | ||||
| function Export() { | ||||
|   const { data: allExports, mutate } = useSWR<ExportItem[]>( | ||||
|     "exports/", | ||||
|     (url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data), | ||||
|   ); | ||||
| function Exports() { | ||||
|   const { data: exports, mutate } = useSWR<Export[]>("exports"); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     document.title = "Export - Frigate"; | ||||
| @ -33,17 +28,18 @@ function Export() { | ||||
| 
 | ||||
|   const [search, setSearch] = useState(""); | ||||
| 
 | ||||
|   const exports = useMemo(() => { | ||||
|     if (!search || !allExports) { | ||||
|       return allExports; | ||||
|   const filteredExports = useMemo(() => { | ||||
|     if (!search || !exports) { | ||||
|       return exports; | ||||
|     } | ||||
| 
 | ||||
|     return allExports.filter((exp) => | ||||
|     return exports.filter((exp) => | ||||
|       exp.name | ||||
|         .toLowerCase() | ||||
|         .includes(search.toLowerCase().replaceAll(" ", "_")), | ||||
|         .replaceAll("_", " ") | ||||
|         .includes(search.toLowerCase()), | ||||
|     ); | ||||
|   }, [allExports, search]); | ||||
|   }, [exports, search]); | ||||
| 
 | ||||
|   // Deleting
 | ||||
| 
 | ||||
| @ -65,8 +61,8 @@ function Export() { | ||||
|   // Renaming
 | ||||
| 
 | ||||
|   const onHandleRename = useCallback( | ||||
|     (original: string, update: string) => { | ||||
|       axios.patch(`export/${original}/${update}`).then((response) => { | ||||
|     (id: string, update: string) => { | ||||
|       axios.patch(`export/${id}/${update}`).then((response) => { | ||||
|         if (response.status == 200) { | ||||
|           setDeleteClip(undefined); | ||||
|           mutate(); | ||||
| @ -76,6 +72,10 @@ function Export() { | ||||
|     [mutate], | ||||
|   ); | ||||
| 
 | ||||
|   // Viewing
 | ||||
| 
 | ||||
|   const [selected, setSelected] = useState<Export>(); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="size-full p-2 overflow-hidden flex flex-col gap-2"> | ||||
|       <AlertDialog | ||||
| @ -91,13 +91,43 @@ function Export() { | ||||
|           </AlertDialogHeader> | ||||
|           <AlertDialogFooter> | ||||
|             <AlertDialogCancel>Cancel</AlertDialogCancel> | ||||
|             <Button variant="destructive" onClick={() => onHandleDelete()}> | ||||
|             <Button | ||||
|               className="text-white" | ||||
|               variant="destructive" | ||||
|               onClick={() => onHandleDelete()} | ||||
|             > | ||||
|               Delete | ||||
|             </Button> | ||||
|           </AlertDialogFooter> | ||||
|         </AlertDialogContent> | ||||
|       </AlertDialog> | ||||
| 
 | ||||
|       <Dialog | ||||
|         open={selected != undefined} | ||||
|         onOpenChange={(open) => { | ||||
|           if (!open) { | ||||
|             setSelected(undefined); | ||||
|           } | ||||
|         }} | ||||
|       > | ||||
|         <DialogContent className="max-w-7xl"> | ||||
|           <DialogTitle>{selected?.name}</DialogTitle> | ||||
|           <video | ||||
|             className="size-full rounded-2xl" | ||||
|             playsInline | ||||
|             preload="auto" | ||||
|             autoPlay | ||||
|             controls | ||||
|             muted | ||||
|           > | ||||
|             <source | ||||
|               src={`${baseUrl}${selected?.video_path?.replace("/media/frigate/", "")}`} | ||||
|               type="video/mp4" | ||||
|             /> | ||||
|           </video> | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
| 
 | ||||
|       <div className="w-full p-2 flex items-center justify-center"> | ||||
|         <Input | ||||
|           className="w-full md:w-1/3 bg-muted" | ||||
| @ -108,17 +138,18 @@ function Export() { | ||||
|       </div> | ||||
| 
 | ||||
|       <div className="w-full overflow-hidden"> | ||||
|         {allExports && exports && ( | ||||
|         {exports && filteredExports && ( | ||||
|           <div className="size-full grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 overflow-y-auto"> | ||||
|             {Object.values(allExports).map((item) => ( | ||||
|             {Object.values(exports).map((item) => ( | ||||
|               <ExportCard | ||||
|                 key={item.name} | ||||
|                 className={ | ||||
|                   search == "" || exports.includes(item) ? "" : "hidden" | ||||
|                   search == "" || filteredExports.includes(item) ? "" : "hidden" | ||||
|                 } | ||||
|                 file={item} | ||||
|                 exportedRecording={item} | ||||
|                 onSelect={setSelected} | ||||
|                 onRename={onHandleRename} | ||||
|                 onDelete={(file) => setDeleteClip(file)} | ||||
|                 onDelete={(id) => setDeleteClip(id)} | ||||
|               /> | ||||
|             ))} | ||||
|           </div> | ||||
| @ -128,4 +159,4 @@ function Export() { | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export default Export; | ||||
| export default Exports; | ||||
							
								
								
									
										9
									
								
								web/src/types/export.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								web/src/types/export.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | ||||
| export type Export = { | ||||
|   id: string; | ||||
|   camera: string; | ||||
|   name: string; | ||||
|   date: number; | ||||
|   video_path: string; | ||||
|   thumb_path: string; | ||||
|   in_progress: boolean; | ||||
| }; | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user