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:
Nicolas Mowen 2024-04-19 16:11:41 -06:00 committed by GitHub
parent 3b0f9988df
commit fe4fb645d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 584 additions and 284 deletions

View File

@ -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
View 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,
)

View File

@ -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,9 +1166,13 @@ 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
try:
preview: Previews = (
Previews.select(
Previews.camera,
@ -1335,6 +1190,8 @@ def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
.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

View File

@ -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:

View File

@ -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)

View File

@ -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()

View File

@ -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:

View 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

View File

@ -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 />} />

View File

@ -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 ? (

View File

@ -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 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"
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={() => {
setPlaying(true);
videoRef.current?.play();
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("_", " ")}
{exportedRecording.name.replaceAll("_", " ")}
</div>
</div>
)}
</div>
</>
);

View File

@ -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
View 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;
};