diff --git a/docs/docs/integrations/api.md b/docs/docs/integrations/api.md index 69b6e7632..b6fc4e601 100644 --- a/docs/docs/integrations/api.md +++ b/docs/docs/integrations/api.md @@ -188,6 +188,14 @@ Returns data for a single event. Permanently deletes the event along with any clips/snapshots. +### `POST /api/events//retain` + +Sets retain to true for the event id. + +### `DELETE /api/events//retain` + +Sets retain to false for the event id (event may be deleted quickly after removing). + ### `GET /api/events//thumbnail.jpg` Returns a thumbnail for the event id optimized for notifications. Works while the event is in progress and after completion. Passing `?format=android` will convert the thumbnail to 2:1 aspect ratio. diff --git a/frigate/events.py b/frigate/events.py index e92a37f56..b90204361 100644 --- a/frigate/events.py +++ b/frigate/events.py @@ -147,6 +147,7 @@ class EventCleanup(threading.Thread): Event.camera.not_in(self.camera_keys), Event.start_time < expire_after, Event.label == l.label, + Event.retain_indefinitely == False, ) # delete the media from disk for event in expired_events: @@ -166,6 +167,7 @@ class EventCleanup(threading.Thread): Event.camera.not_in(self.camera_keys), Event.start_time < expire_after, Event.label == l.label, + Event.retain_indefinitely == False, ) update_query.execute() @@ -192,6 +194,7 @@ class EventCleanup(threading.Thread): Event.camera == name, Event.start_time < expire_after, Event.label == l.label, + Event.retain_indefinitely == False, ) # delete the grabbed clips from disk for event in expired_events: @@ -210,6 +213,7 @@ class EventCleanup(threading.Thread): Event.camera == name, Event.start_time < expire_after, Event.label == l.label, + Event.retain_indefinitely == False, ) update_query.execute() diff --git a/frigate/http.py b/frigate/http.py index 7fa174369..c8141bd2e 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -120,6 +120,40 @@ def event(id): return "Event not found", 404 +@bp.route("/events//retain", methods=("POST",)) +def set_retain(id): + try: + event = Event.get(Event.id == id) + except DoesNotExist: + return make_response( + jsonify({"success": False, "message": "Event" + id + " not found"}), 404 + ) + + event.retain_indefinitely = True + event.save() + + return make_response( + jsonify({"success": True, "message": "Event" + id + " retained"}), 200 + ) + + +@bp.route("/events//retain", methods=("DELETE",)) +def delete_retain(id): + try: + event = Event.get(Event.id == id) + except DoesNotExist: + return make_response( + jsonify({"success": False, "message": "Event" + id + " not found"}), 404 + ) + + event.retain_indefinitely = False + event.save() + + return make_response( + jsonify({"success": True, "message": "Event" + id + " un-retained"}), 200 + ) + + @bp.route("/events/", methods=("DELETE",)) def delete_event(id): try: diff --git a/frigate/models.py b/frigate/models.py index 35a397f5c..ff83dcb54 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -18,6 +18,7 @@ class Event(Model): region = JSONField() box = JSONField() area = IntegerField() + retain_indefinitely = BooleanField(default=False) class Recordings(Model): diff --git a/migrations/007_add_retain_indefinitely.py b/migrations/007_add_retain_indefinitely.py new file mode 100644 index 000000000..a46b72e29 --- /dev/null +++ b/migrations/007_add_retain_indefinitely.py @@ -0,0 +1,46 @@ +"""Peewee migrations -- 007_add_retain_indefinitely.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 datetime as dt +import peewee as pw +from playhouse.sqlite_ext import * +from decimal import ROUND_HALF_EVEN +from frigate.models import Event + +try: + import playhouse.postgres_ext as pw_pext +except ImportError: + pass + +SQL = pw.SQL + + +def migrate(migrator, database, fake=False, **kwargs): + migrator.add_fields( + Event, + retain_indefinitely=pw.BooleanField(default=False), + ) + + +def rollback(migrator, database, fake=False, **kwargs): + migrator.remove_fields(Event, ["retain_indefinitely"]) diff --git a/web/src/api/index.jsx b/web/src/api/index.jsx index f6ef556d8..a2f7d1d75 100644 --- a/web/src/api/index.jsx +++ b/web/src/api/index.jsx @@ -117,6 +117,24 @@ export function useDelete() { return deleteEvent; } +export function useRetain() { + const { state } = useContext(Api); + + async function retainEvent(eventId, shouldRetain) { + if (!eventId) return null; + + if (shouldRetain) { + const response = await fetch(`${state.host}/api/events/${eventId}/retain`, { method: 'POST' }); + return await (response.status < 300 ? response.json() : { success: true }); + } else { + const response = await fetch(`${state.host}/api/events/${eventId}/retain`, { method: 'DELETE' }); + return await (response.status < 300 ? response.json() : { success: true }); + } + } + + return retainEvent; +} + export function useApiHost() { const { state } = useContext(Api); return state.host; diff --git a/web/src/components/Button.jsx b/web/src/components/Button.jsx index 031010dae..9cbb14a1a 100644 --- a/web/src/components/Button.jsx +++ b/web/src/components/Button.jsx @@ -17,6 +17,13 @@ const ButtonColors = { text: 'text-red-500 hover:bg-red-500 hover:bg-opacity-20 focus:bg-red-500 focus:bg-opacity-40 active:bg-red-500 active:bg-opacity-40', }, + yellow: { + contained: 'bg-yellow-500 focus:bg-yellow-400 active:bg-yellow-600 ring-yellow-300', + outlined: + 'text-yellow-500 border-2 border-yellow-500 hover:bg-yellow-500 hover:bg-opacity-20 focus:bg-yellow-500 focus:bg-opacity-40 active:bg-yellow-500 active:bg-opacity-40', + text: + 'text-yellow-500 hover:bg-yellow-500 hover:bg-opacity-20 focus:bg-yellow-500 focus:bg-opacity-40 active:bg-yellow-500 active:bg-opacity-40', + }, green: { contained: 'bg-green-500 focus:bg-green-400 active:bg-green-600 ring-green-300', outlined: diff --git a/web/src/icons/StarRecording.jsx b/web/src/icons/StarRecording.jsx new file mode 100644 index 000000000..eebf8e4aa --- /dev/null +++ b/web/src/icons/StarRecording.jsx @@ -0,0 +1,12 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function StarRecording({ className = '' }) { + return ( + + + + ); +} + +export default memo(StarRecording); diff --git a/web/src/routes/Event.jsx b/web/src/routes/Event.jsx index 48e53e4e9..2b58bc698 100644 --- a/web/src/routes/Event.jsx +++ b/web/src/routes/Event.jsx @@ -7,16 +7,21 @@ import ArrowDown from '../icons/ArrowDropdown'; import ArrowDropup from '../icons/ArrowDropup'; import Clip from '../icons/Clip'; import Close from '../icons/Close'; +import StarRecording from '../icons/StarRecording'; import Delete from '../icons/Delete'; import Snapshot from '../icons/Snapshot'; import Dialog from '../components/Dialog'; import Heading from '../components/Heading'; import VideoPlayer from '../components/VideoPlayer'; import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table'; -import { FetchStatus, useApiHost, useEvent, useDelete } from '../api'; +import { FetchStatus, useApiHost, useEvent, useDelete, useRetain } from '../api'; -const ActionButtonGroup = ({ className, handleClickDelete, close }) => ( +const ActionButtonGroup = ({ className, isRetained, handleClickRetain, handleClickDelete, close }) => (
+ @@ -54,6 +59,8 @@ export default function Event({ eventId, close, scrollRef }) { const [showDetails, setShowDetails] = useState(false); const [shouldScroll, setShouldScroll] = useState(true); const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE); + const [isRetained, setIsRetained] = useState(false); + const setRetainEvent = useRetain(); const setDeleteEvent = useDelete(); useEffect(() => { @@ -71,6 +78,22 @@ export default function Event({ eventId, close, scrollRef }) { }; }, [data, scrollRef, eventId, shouldScroll]); + const handleClickRetain = useCallback(async () => { + let success; + try { + success = await setRetainEvent(eventId, !isRetained); + + if (success) { + setIsRetained(!isRetained); + + // Need to reload page otherwise retain button state won't stick if event is collapsed and re-opened. + window.location.reload(); + } + } catch (e) { + + } + }, [eventId, isRetained, setRetainEvent]); + const handleClickDelete = () => { setShowDialog(true); }; @@ -98,6 +121,7 @@ export default function Event({ eventId, close, scrollRef }) { return ; } + setIsRetained(data.retain_indefinitely); const startime = new Date(data.start_time * 1000); const endtime = data.end_time ? new Date(data.end_time * 1000) : null; return ( @@ -119,7 +143,7 @@ export default function Event({ eventId, close, scrollRef }) { )}
- + {showDialog ? (
- +
); diff --git a/web/src/routes/Events/components/tableHead.jsx b/web/src/routes/Events/components/tableHead.jsx index 69d60d65b..ea5afe8c4 100644 --- a/web/src/routes/Events/components/tableHead.jsx +++ b/web/src/routes/Events/components/tableHead.jsx @@ -9,6 +9,7 @@ const TableHead = () => ( Label Score Zones + Retain Date Start End diff --git a/web/src/routes/Events/components/tableRow.jsx b/web/src/routes/Events/components/tableRow.jsx index f358153b2..ddf4d4582 100644 --- a/web/src/routes/Events/components/tableRow.jsx +++ b/web/src/routes/Events/components/tableRow.jsx @@ -22,6 +22,7 @@ const EventsRow = memo( label, top_score: score, zones, + retain_indefinitely }) => { const [viewEvent, setViewEvent] = useState(null); const { searchString, removeDefaultSearchKeys } = useSearchString(limit); @@ -100,6 +101,7 @@ const EventsRow = memo( ))} + {retain_indefinitely ? 'True' : 'False'} {start.toLocaleDateString()} {start.toLocaleTimeString()} {end === null ? 'In progress' : end.toLocaleTimeString()}