mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-02-23 00:18:31 +01:00
Ability to retain specific clips / events indefinitely (#2831)
This commit is contained in:
parent
cbf26e09a4
commit
4004048add
@ -188,6 +188,14 @@ Returns data for a single event.
|
||||
|
||||
Permanently deletes the event along with any clips/snapshots.
|
||||
|
||||
### `POST /api/events/<id>/retain`
|
||||
|
||||
Sets retain to true for the event id.
|
||||
|
||||
### `DELETE /api/events/<id>/retain`
|
||||
|
||||
Sets retain to false for the event id (event may be deleted quickly after removing).
|
||||
|
||||
### `GET /api/events/<id>/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.
|
||||
|
@ -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()
|
||||
|
||||
|
@ -120,6 +120,40 @@ def event(id):
|
||||
return "Event not found", 404
|
||||
|
||||
|
||||
@bp.route("/events/<id>/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/<id>/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/<id>", methods=("DELETE",))
|
||||
def delete_event(id):
|
||||
try:
|
||||
|
@ -18,6 +18,7 @@ class Event(Model):
|
||||
region = JSONField()
|
||||
box = JSONField()
|
||||
area = IntegerField()
|
||||
retain_indefinitely = BooleanField(default=False)
|
||||
|
||||
|
||||
class Recordings(Model):
|
||||
|
46
migrations/007_add_retain_indefinitely.py
Normal file
46
migrations/007_add_retain_indefinitely.py
Normal file
@ -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"])
|
@ -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;
|
||||
|
@ -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:
|
||||
|
12
web/src/icons/StarRecording.jsx
Normal file
12
web/src/icons/StarRecording.jsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function StarRecording({ className = '' }) {
|
||||
return (
|
||||
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6m.5 16.9L12 17.5 9.5 19l.7-2.8L8 14.3l2.9-.2 1.1-2.7 1.1 2.6 2.9.2-2.2 1.9.7 2.8M13 9V3.5L18.5 9H13z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(StarRecording);
|
@ -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 }) => (
|
||||
<div className={`space-y-2 space-x-2 sm:space-y-0 xs:space-x-4 ${className}`}>
|
||||
<Button className="xs:w-auto" color={isRetained ? 'red' : 'yellow'} onClick={handleClickRetain}>
|
||||
<StarRecording className="w-6" />
|
||||
{isRetained ? ('Un-retain event') : ('Retain event')}
|
||||
</Button>
|
||||
<Button className="xs:w-auto" color="red" onClick={handleClickDelete}>
|
||||
<Delete className="w-6" /> Delete event
|
||||
</Button>
|
||||
@ -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 <ActivityIndicator />;
|
||||
}
|
||||
|
||||
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 }) {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<ActionButtonGroup handleClickDelete={handleClickDelete} close={close} className="hidden sm:block" />
|
||||
<ActionButtonGroup isRetained={isRetained} handleClickRetain={handleClickRetain} handleClickDelete={handleClickDelete} close={close} className="hidden sm:block" />
|
||||
{showDialog ? (
|
||||
<Dialog
|
||||
onDismiss={handleDismissDeleteDialog}
|
||||
@ -210,7 +234,7 @@ export default function Event({ eventId, close, scrollRef }) {
|
||||
</div>
|
||||
<div className="space-y-2 xs:space-y-0">
|
||||
<DownloadButtonGroup apiHost={apiHost} eventId={eventId} className="block sm:hidden" />
|
||||
<ActionButtonGroup handleClickDelete={handleClickDelete} close={close} className="block sm:hidden" />
|
||||
<ActionButtonGroup isRetained={isRetained} handleClickRetain={handleClickRetain} handleClickDelete={handleClickDelete} close={close} className="block sm:hidden" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -9,6 +9,7 @@ const TableHead = () => (
|
||||
<Th>Label</Th>
|
||||
<Th>Score</Th>
|
||||
<Th>Zones</Th>
|
||||
<Th>Retain</Th>
|
||||
<Th>Date</Th>
|
||||
<Th>Start</Th>
|
||||
<Th>End</Th>
|
||||
|
@ -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(
|
||||
))}
|
||||
</ul>
|
||||
</Td>
|
||||
<Td>{retain_indefinitely ? 'True' : 'False'}</Td>
|
||||
<Td>{start.toLocaleDateString()}</Td>
|
||||
<Td>{start.toLocaleTimeString()}</Td>
|
||||
<Td>{end === null ? 'In progress' : end.toLocaleTimeString()}</Td>
|
||||
|
Loading…
Reference in New Issue
Block a user