mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-01 19:30:33 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b92ad23116 | ||
|
|
72c97f2b7d | ||
|
|
fac65fdca2 | ||
|
|
309a4e5aa8 | ||
|
|
f246db3f4e | ||
|
|
9fca3f276d | ||
|
|
f68f360386 | ||
|
|
5764eaddaa |
@@ -55,6 +55,9 @@ updates:
|
||||
# Needs Python 3.12 and numpy 2
|
||||
- dependency-name: "librosa"
|
||||
versions: [">=1.0"]
|
||||
# pandas 3.1 requires numpy 2
|
||||
- dependency-name: "pandas"
|
||||
versions: [">=3.1"]
|
||||
# Needs Python 3.12
|
||||
- dependency-name: "scipy"
|
||||
versions: [">=1.18"]
|
||||
|
||||
Vendored
+2
@@ -6371,6 +6371,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Padding to apply to the vod.
|
||||
default: 0
|
||||
title: Padding
|
||||
@@ -7576,6 +7577,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 30
|
||||
title: Scale
|
||||
responses:
|
||||
|
||||
@@ -966,6 +966,10 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
try:
|
||||
db_user: User = User.get_by_id(user)
|
||||
except DoesNotExist:
|
||||
# Hash anyway so response timing doesn't reveal which usernames exist
|
||||
hash_password(
|
||||
password, iterations=request.app.frigate_config.auth.hash_iterations
|
||||
)
|
||||
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
|
||||
failed_logins.record(user, time.time())
|
||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class MediaRecordingsAvailabilityQueryParams(BaseModel):
|
||||
cameras: str = "all"
|
||||
before: float | SkipJsonSchema[None] = None
|
||||
after: float | SkipJsonSchema[None] = None
|
||||
scale: int = 30
|
||||
scale: int = Field(default=30, ge=1)
|
||||
|
||||
|
||||
class RecordingsDeleteQueryParams(BaseModel):
|
||||
|
||||
@@ -291,12 +291,12 @@ def events(
|
||||
clauses.append(
|
||||
reduce(
|
||||
operator.or_,
|
||||
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
|
||||
[(start_hour_fun >= time_after), (start_hour_fun < time_before)],
|
||||
)
|
||||
)
|
||||
# all other cases should be and operator
|
||||
else:
|
||||
clauses.append(start_hour_fun > time_after)
|
||||
clauses.append(start_hour_fun >= time_after)
|
||||
clauses.append(start_hour_fun < time_before)
|
||||
|
||||
if has_clip is not None:
|
||||
@@ -749,12 +749,12 @@ def events_search(
|
||||
event_filters.append(
|
||||
reduce(
|
||||
operator.or_,
|
||||
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
|
||||
[(start_hour_fun >= time_after), (start_hour_fun < time_before)],
|
||||
)
|
||||
)
|
||||
# all other cases should be and operator
|
||||
else:
|
||||
event_filters.append(start_hour_fun > time_after)
|
||||
event_filters.append(start_hour_fun >= time_after)
|
||||
event_filters.append(start_hour_fun < time_before)
|
||||
|
||||
# Perform semantic search
|
||||
|
||||
@@ -1227,7 +1227,7 @@ def bulk_delete_exports(body: ExportBulkDeleteBody):
|
||||
files_in_use = _get_files_in_use()
|
||||
|
||||
for export in exports:
|
||||
if export.video_path.split("/")[-1] in files_in_use:
|
||||
if export.in_progress or export.video_path.split("/")[-1] in files_in_use:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
|
||||
@@ -862,7 +862,7 @@ async def vod_hour(
|
||||
async def vod_event(
|
||||
request: Request,
|
||||
event_id: str,
|
||||
padding: int = Query(0, description="Padding to apply to the vod."),
|
||||
padding: int = Query(0, ge=0, description="Padding to apply to the vod."),
|
||||
):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
@@ -1521,6 +1521,7 @@ async def preview_gif(
|
||||
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
|
||||
)
|
||||
.where(Previews.camera == camera_name)
|
||||
.order_by(Previews.start_time.asc())
|
||||
.limit(1)
|
||||
.get()
|
||||
)
|
||||
@@ -1530,7 +1531,8 @@ async def preview_gif(
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
diff = start_ts - preview.start_time
|
||||
clip_start = max(start_ts, preview.start_time)
|
||||
diff = clip_start - preview.start_time
|
||||
minutes = int(diff / 60)
|
||||
seconds = int(diff % 60)
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
@@ -1542,7 +1544,7 @@ async def preview_gif(
|
||||
"-ss",
|
||||
f"00:{minutes}:{seconds}",
|
||||
"-t",
|
||||
f"{end_ts - start_ts}",
|
||||
f"{end_ts - clip_start}",
|
||||
"-i",
|
||||
preview.path,
|
||||
"-r",
|
||||
@@ -1709,6 +1711,7 @@ async def preview_mp4(
|
||||
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
|
||||
)
|
||||
.where(Previews.camera == camera_name)
|
||||
.order_by(Previews.start_time.asc())
|
||||
.limit(1)
|
||||
.get()
|
||||
)
|
||||
@@ -1721,7 +1724,8 @@ async def preview_mp4(
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
diff = start_ts - preview.start_time
|
||||
clip_start = max(start_ts, preview.start_time)
|
||||
diff = clip_start - preview.start_time
|
||||
minutes = int(diff / 60)
|
||||
seconds = int(diff % 60)
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
@@ -1734,7 +1738,7 @@ async def preview_mp4(
|
||||
"-ss",
|
||||
f"00:{minutes}:{seconds}",
|
||||
"-t",
|
||||
f"{end_ts - start_ts}",
|
||||
f"{end_ts - clip_start}",
|
||||
"-i",
|
||||
preview.path,
|
||||
"-r",
|
||||
|
||||
@@ -163,8 +163,7 @@ async def review(
|
||||
|
||||
# Apply ordering and limit
|
||||
review_query = (
|
||||
review_query.order_by(ReviewSegment.severity.asc())
|
||||
.order_by(ReviewSegment.start_time.desc())
|
||||
review_query.order_by(ReviewSegment.start_time.desc())
|
||||
.limit(limit)
|
||||
.dicts()
|
||||
.iterator()
|
||||
|
||||
@@ -1470,6 +1470,9 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
try:
|
||||
self._run_export(video_path, thumb_path)
|
||||
except Exception:
|
||||
self._discard_failed_export(video_path, thumb_path)
|
||||
raise
|
||||
finally:
|
||||
# staged runs hold a full copy of the export, so they must not
|
||||
# survive a failure the way the small chapter file could
|
||||
@@ -1479,7 +1482,8 @@ class RecordingExporter(threading.Thread):
|
||||
"""Drop the partial output and the row that promised it."""
|
||||
Path(video_path).unlink(missing_ok=True)
|
||||
Export.delete().where(Export.id == self.export_id).execute()
|
||||
Path(thumb_path).unlink(missing_ok=True)
|
||||
if thumb_path:
|
||||
Path(thumb_path).unlink(missing_ok=True)
|
||||
|
||||
def _run_export(self, video_path: str, thumb_path: str) -> None:
|
||||
try:
|
||||
@@ -1498,6 +1502,7 @@ class RecordingExporter(threading.Thread):
|
||||
else:
|
||||
ffmpeg_cmd, playlist_lines = self.get_preview_export_command(video_path)
|
||||
except DoesNotExist:
|
||||
self._discard_failed_export(video_path, thumb_path)
|
||||
return
|
||||
|
||||
# When neither custom ffmpeg arg is set the default path uses
|
||||
|
||||
@@ -393,8 +393,8 @@ class TestHttpApp(BaseTestHttp):
|
||||
def test_event_time_filtering(self):
|
||||
morning_id = "123456.random"
|
||||
evening_id = "654321.random"
|
||||
morning = 1656590400 # 06/30/2022 6 am (GMT)
|
||||
evening = 1656633600 # 06/30/2022 6 pm (GMT)
|
||||
morning = 1656568800 # 06/30/2022 6 am (GMT)
|
||||
evening = 1656612000 # 06/30/2022 6 pm (GMT)
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_event(morning_id, morning)
|
||||
@@ -417,6 +417,12 @@ class TestHttpApp(BaseTestHttp):
|
||||
).json()
|
||||
assert events
|
||||
assert len(events) == 1
|
||||
# an event in the first minute of the range is included
|
||||
events = client.get(
|
||||
"/events",
|
||||
params={"time_range": "06:00,24:00"},
|
||||
).json()
|
||||
assert len(events) == 2
|
||||
|
||||
def test_set_delete_sub_label(self):
|
||||
mock_event_updater = Mock(spec=EventMetadataPublisher)
|
||||
|
||||
@@ -427,6 +427,30 @@ class TestHttpExport(BaseTestHttp):
|
||||
assert response.status_code == 400
|
||||
assert Export.get(Export.id == "front_door_running").video_path == video
|
||||
|
||||
def test_delete_export_rejected_while_in_progress(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
video = os.path.join(tmpdir, "front_door_abc123.mp4")
|
||||
with open(video, "wb") as handle:
|
||||
handle.write(b"video")
|
||||
|
||||
Export.create(
|
||||
id="front_door_running",
|
||||
camera="front_door",
|
||||
name="front door export",
|
||||
date=100,
|
||||
video_path=video,
|
||||
thumb_path=os.path.join(tmpdir, "t.webp"),
|
||||
in_progress=True,
|
||||
)
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
response = client.post(
|
||||
"/exports/delete", json={"ids": ["front_door_running"]}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert os.path.exists(video)
|
||||
|
||||
def test_rename_export_missing_file_leaves_the_row_alone(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
video = os.path.join(tmpdir, "front_door_gone_abc123.mp4")
|
||||
|
||||
@@ -584,6 +584,10 @@ class TestHttpMedia(BaseTestHttp):
|
||||
{"start_time": 1160, "end_time": 1300},
|
||||
]
|
||||
|
||||
# a non-positive scale would never advance the gap scan
|
||||
response = client.get("/recordings/unavailable", params={"scale": 0})
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_recordings_summary_day_attribution_by_start_time(self):
|
||||
"""
|
||||
A recording spanning midnight marks only its start day.
|
||||
|
||||
@@ -415,10 +415,19 @@ export default function NotificationsSettingsExtras({
|
||||
pushSubscription.unsubscribe();
|
||||
workerRegistration.unregister();
|
||||
setRegistration(null);
|
||||
removeMessage("notification_settings", "registration");
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("notification.toast.error.registerFailed"), {
|
||||
position: "top-center",
|
||||
});
|
||||
workerRegistration.unregister();
|
||||
setRegistration(null);
|
||||
removeMessage("notification_settings", "registration");
|
||||
});
|
||||
},
|
||||
[addMessage, publicKey, t],
|
||||
[addMessage, removeMessage, publicKey, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -307,6 +307,9 @@ export function KnownPlatesField(props: FieldProps) {
|
||||
);
|
||||
|
||||
const handleAddEntry = useCallback(() => {
|
||||
if ("" in data) {
|
||||
return;
|
||||
}
|
||||
const next = { ...data, "": [""] };
|
||||
onChange(next, fieldPath);
|
||||
}, [data, fieldPath, onChange]);
|
||||
|
||||
@@ -690,36 +690,8 @@ export function ExportContent({
|
||||
(option: ExportOption) => {
|
||||
setSelectedOption(option);
|
||||
|
||||
const now = new Date(latestTime * 1000);
|
||||
let start: number;
|
||||
|
||||
switch (option) {
|
||||
case "1":
|
||||
now.setHours(now.getHours() - 1);
|
||||
start = now.getTime() / 1000;
|
||||
break;
|
||||
case "4":
|
||||
now.setHours(now.getHours() - 4);
|
||||
start = now.getTime() / 1000;
|
||||
break;
|
||||
case "8":
|
||||
now.setHours(now.getHours() - 8);
|
||||
start = now.getTime() / 1000;
|
||||
break;
|
||||
case "12":
|
||||
now.setHours(now.getHours() - 12);
|
||||
start = now.getTime() / 1000;
|
||||
break;
|
||||
case "24":
|
||||
now.setHours(now.getHours() - 24);
|
||||
start = now.getTime() / 1000;
|
||||
break;
|
||||
case "custom":
|
||||
start = latestTime - 3600;
|
||||
break;
|
||||
default:
|
||||
start = latestTime - 3600;
|
||||
}
|
||||
const hours = Number(option);
|
||||
const start = latestTime - (Number.isNaN(hours) ? 1 : hours) * 3600;
|
||||
|
||||
setRange({
|
||||
before: latestTime,
|
||||
|
||||
@@ -147,7 +147,18 @@ export default function DetailStream({
|
||||
// away instead of waiting for a future effectiveTime change.
|
||||
useEffect(() => {
|
||||
if (!reviewItems || reviewItems.length === 0) return;
|
||||
if (activeReviewId) return;
|
||||
|
||||
// an id left over from another camera's items counts as unset
|
||||
if (
|
||||
activeReviewId &&
|
||||
reviewItems.some(
|
||||
(r) =>
|
||||
`review-${r.id ?? r.start_time ?? Math.floor(r.start_time ?? 0)}` ===
|
||||
activeReviewId,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let target: ReviewSegment | undefined;
|
||||
let closest: { r: ReviewSegment; diff: number } | undefined;
|
||||
|
||||
@@ -45,8 +45,7 @@ export function createRecordingReviewUrl(
|
||||
pathname: string,
|
||||
state: RecordingReviewLinkState,
|
||||
): string {
|
||||
const url = new URL(baseUrl);
|
||||
url.pathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
||||
const url = new URL(pathname.replace(/^\//, ""), baseUrl);
|
||||
url.searchParams.set(
|
||||
RECORDING_REVIEW_LINK_PARAM,
|
||||
`${state.camera}_${Math.floor(state.timestamp)}`,
|
||||
|
||||
@@ -575,25 +575,19 @@ export default function DraggableGridLayout({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!allGroupsStreamingSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialAudioStates: AudioState = {};
|
||||
const initialVolumeStates: VolumeState = {};
|
||||
|
||||
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
|
||||
if (groupSettings) {
|
||||
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
|
||||
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
|
||||
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
Object.entries(currentGroupStreamingSettings ?? {}).forEach(
|
||||
([camera, cameraSettings]) => {
|
||||
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
|
||||
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
|
||||
},
|
||||
);
|
||||
|
||||
setAudioStates(initialAudioStates);
|
||||
setVolumeStates(initialVolumeStates);
|
||||
}, [allGroupsStreamingSettings]);
|
||||
}, [currentGroupStreamingSettings, cameraGroup]);
|
||||
|
||||
const toggleAudio = (cameraName: string) => {
|
||||
setAudioStates((prev) => ({
|
||||
@@ -800,7 +794,7 @@ export default function DraggableGridLayout({
|
||||
toggleAudio={() => toggleAudio(camera.name)}
|
||||
statsState={statsStates[camera.name]}
|
||||
toggleStats={() => toggleStats(camera.name)}
|
||||
volumeState={volumeStates[camera.name]}
|
||||
volumeState={volumeStates[camera.name] ?? 1}
|
||||
setVolumeState={(value) =>
|
||||
setVolumeStates((prev) => ({
|
||||
...prev,
|
||||
@@ -845,7 +839,7 @@ export default function DraggableGridLayout({
|
||||
onError={(e) => handleError(camera.name, e)}
|
||||
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
||||
playAudio={audioStates[camera.name]}
|
||||
volume={volumeStates[camera.name]}
|
||||
volume={volumeStates[camera.name] ?? 1}
|
||||
onLiveAspectChange={liveAspectHandlers[camera.name]}
|
||||
/>
|
||||
</GridLiveContextMenu>
|
||||
|
||||
@@ -511,7 +511,14 @@ export default function LiveCameraView({
|
||||
}
|
||||
}, [webRTCVerdictPending]);
|
||||
|
||||
useKeyboardListener(["m", "Escape"], (key, modifiers) => {
|
||||
const toggleMic = () => {
|
||||
setMic(!mic);
|
||||
if (!mic && !audio) {
|
||||
setAudio(true);
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboardListener(["m", "t", "Escape"], (key, modifiers) => {
|
||||
if (!modifiers.down) {
|
||||
return true;
|
||||
}
|
||||
@@ -525,7 +532,7 @@ export default function LiveCameraView({
|
||||
break;
|
||||
case "t":
|
||||
if (supports2WayTalk && talkAvailable) {
|
||||
setMic(!mic);
|
||||
toggleMic();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
@@ -792,12 +799,7 @@ export default function LiveCameraView({
|
||||
? t("twoWayTalk.disable", { ns: "views/live" })
|
||||
: t("twoWayTalk.enable", { ns: "views/live" })
|
||||
}
|
||||
onClick={() => {
|
||||
setMic(!mic);
|
||||
if (!mic && !audio) {
|
||||
setAudio(true);
|
||||
}
|
||||
}}
|
||||
onClick={toggleMic}
|
||||
disabled={!cameraEnabled || debug || !talkAvailable}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -326,25 +326,19 @@ export default function LiveDashboardView({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!allGroupsStreamingSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialAudioStates: AudioState = {};
|
||||
const initialVolumeStates: VolumeState = {};
|
||||
|
||||
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
|
||||
if (groupSettings) {
|
||||
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
|
||||
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
|
||||
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
Object.entries(currentGroupStreamingSettings ?? {}).forEach(
|
||||
([camera, cameraSettings]) => {
|
||||
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
|
||||
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
|
||||
},
|
||||
);
|
||||
|
||||
setAudioStates(initialAudioStates);
|
||||
setVolumeStates(initialVolumeStates);
|
||||
}, [allGroupsStreamingSettings]);
|
||||
}, [currentGroupStreamingSettings, cameraGroup]);
|
||||
|
||||
const toggleAudio = (cameraName: string): void => {
|
||||
setAudioStates((prev) => ({
|
||||
@@ -635,7 +629,7 @@ export default function LiveDashboardView({
|
||||
resetPreferredLiveMode(camera.name)
|
||||
}
|
||||
playAudio={audioStates[camera.name] ?? false}
|
||||
volume={volumeStates[camera.name]}
|
||||
volume={volumeStates[camera.name] ?? 1}
|
||||
/>
|
||||
</LiveContextMenu>
|
||||
);
|
||||
|
||||
@@ -86,17 +86,21 @@ export default function MediaSyncSettingsView() {
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
});
|
||||
} else if (response.status === 409) {
|
||||
toast.error(t("maintenance.sync.alreadyRunning"), {
|
||||
}
|
||||
} catch (error) {
|
||||
const alreadyRunning =
|
||||
axios.isAxiosError(error) && error.response?.status === 409;
|
||||
toast.error(
|
||||
t(
|
||||
alreadyRunning
|
||||
? "maintenance.sync.alreadyRunning"
|
||||
: "maintenance.sync.error",
|
||||
),
|
||||
{
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("maintenance.sync.error"), {
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
@@ -133,6 +133,9 @@ export default function TriggerView({
|
||||
|
||||
// watch websocket for updates
|
||||
const { payload: triggers_status_ws } = useTriggers();
|
||||
const highlightTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>(
|
||||
{},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggers_status_ws) return;
|
||||
@@ -148,13 +151,13 @@ export default function TriggerView({
|
||||
return current;
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const name = triggers_status_ws.name;
|
||||
clearTimeout(highlightTimers.current[name]);
|
||||
highlightTimers.current[name] = setTimeout(() => {
|
||||
setTriggeredTrigger((prev) =>
|
||||
(prev || []).filter((name) => name !== triggers_status_ws.name),
|
||||
(prev || []).filter((triggered) => triggered !== name),
|
||||
);
|
||||
}, 3000);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [triggers_status_ws, selectedCamera, mutate]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user