Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
df069657b8
Merge 5d0d89a3f2 into d556ff8df2 2026-05-23 03:58:51 +02:00
13 changed files with 114 additions and 253 deletions

View File

@ -547,21 +547,9 @@ async def _execute_get_live_context(
camera: str,
allowed_cameras: List[str],
) -> Dict[str, Any]:
# Reject wildcards explicitly so models retry with a real camera name
# instead of silently fanning out across every camera.
if camera in ("*", "all"):
return {
"error": (
"get_live_context requires a single camera name; wildcards "
"are not supported. Call this tool once per camera."
),
"available_cameras": allowed_cameras,
}
if camera not in allowed_cameras:
return {
"error": f"Camera '{camera}' not found or access denied",
"available_cameras": allowed_cameras,
}
if camera not in request.app.frigate_config.cameras:
@ -733,14 +721,7 @@ async def _execute_tool_internal(
"Arguments: %s",
json.dumps(arguments),
)
return {
"error": (
"get_live_context requires a single camera name; "
"wildcards and empty values are not supported. "
"Call this tool once per camera."
),
"available_cameras": allowed_cameras,
}
return {"error": "Camera parameter is required"}
return await _execute_get_live_context(request, camera, allowed_cameras)
elif tool_name == "start_camera_watch":
return await _execute_start_camera_watch(request, arguments)

View File

@ -518,21 +518,16 @@ def get_tool_definitions(
"function": {
"name": "get_live_context",
"description": (
"Get the current live image and detection information for a single camera: objects being tracked, "
"Get the current live image and detection information for a camera: objects being tracked, "
"zones, timestamps. Use this to understand what is visible in the live view. "
"Call this when answering questions about what is happening right now on a specific camera. "
"Operates on one camera at a time; call the tool again for each additional camera. "
"Wildcards and empty values are not accepted."
"Call this when answering questions about what is happening right now on a specific camera."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": (
"Exact name of a single camera to get live context for. "
"Wildcards (e.g. '*', 'all') and empty strings are not accepted."
),
"description": "Camera name to get live context for.",
},
},
"required": ["camera"],

View File

@ -579,9 +579,7 @@ class RecordingExporter(threading.Thread):
else:
chapters_path = self._build_chapter_metadata_file(recordings)
chapter_args = (
f" -i {chapters_path} -map 0 -dn -map_metadata 1"
if chapters_path
else ""
f" -i {chapters_path} -map 0 -map_metadata 1" if chapters_path else ""
)
ffmpeg_cmd = (
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart"

View File

@ -478,7 +478,7 @@ def get_intel_gpu_stats(
overall_pct = min(100.0, compute_pct + dec_pct)
entry: dict[str, Any] = {
"name": names.get(pdev) or "Intel iGPU",
"name": names.get(pdev) or f"Intel GPU {pdev}",
"vendor": "intel",
"gpu": f"{round(overall_pct, 2)}%",
"mem": "-%",

View File

@ -130,15 +130,9 @@ export default function SearchResultActions({
},
);
} else {
toast.error(
t("dialog.toast.error", {
ns: "views/replay",
error: errorMessage,
}),
{
position: "top-center",
},
);
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
}
})
.finally(() => {
@ -212,7 +206,7 @@ export default function SearchResultActions({
<span>{t("itemMenu.addTrigger.label")}</span>
</MenuItem>
)}
{isAdmin && searchResult.has_clip && (
{searchResult.has_clip && (
<MenuItem
className="cursor-pointer"
aria-label={t("itemMenu.debugReplay.aria")}

View File

@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { FaFilm } from "react-icons/fa6";
type ActionsDropdownProps = {
onDebugReplayClick?: () => void;
onDebugReplayClick: () => void;
onExportClick: () => void;
onShareTimestampClick: () => void;
};
@ -42,11 +42,9 @@ export default function ActionsDropdown({
<DropdownMenuItem onClick={onShareTimestampClick}>
{t("recording.shareTimestamp.label", { ns: "components/dialog" })}
</DropdownMenuItem>
{onDebugReplayClick && (
<DropdownMenuItem onClick={onDebugReplayClick}>
{t("title", { ns: "views/replay" })}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onDebugReplayClick}>
{t("title", { ns: "views/replay" })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@ -29,7 +29,6 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { StartExportResponse } from "@/types/export";
import { ShareTimestampContent } from "./ShareTimestampDialog";
import { useIsAdmin } from "@/hooks/use-is-admin";
type DrawerMode =
| "none"
@ -110,7 +109,6 @@ export default function MobileReviewSettingsDrawer({
"views/replay",
"common",
]);
const isAdmin = useIsAdmin();
const navigate = useNavigate();
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
const [exportTab, setExportTab] = useState<ExportTab>("export");
@ -390,7 +388,7 @@ export default function MobileReviewSettingsDrawer({
{t("filter")}
</Button>
)}
{isAdmin && features.includes("debug-replay") && (
{features.includes("debug-replay") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("title", { ns: "views/replay" })}

View File

@ -95,15 +95,9 @@ export default function DetailActionsMenu({
),
});
} else {
toast.error(
t("dialog.toast.error", {
ns: "views/replay",
error: errorMessage,
}),
{
position: "top-center",
},
);
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
}
})
.finally(() => {
@ -235,7 +229,7 @@ export default function DetailActionsMenu({
</DropdownMenuItem>
)}
{isAdmin && search.has_clip && (
{search.has_clip && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("itemMenu.debugReplay.aria")}

View File

@ -94,15 +94,9 @@ export default function EventMenu({
},
);
} else {
toast.error(
t("dialog.toast.error", {
ns: "views/replay",
error: errorMessage,
}),
{
position: "top-center",
},
);
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
}
})
.finally(() => {
@ -183,7 +177,7 @@ export default function EventMenu({
{t("itemMenu.findSimilar.label")}
</DropdownMenuItem>
)}
{isAdmin && event.has_clip && (
{event.has_clip && (
<DropdownMenuItem
className="cursor-pointer"
disabled={isStarting}

View File

@ -16,11 +16,6 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import {
useCallback,
@ -594,7 +589,7 @@ function MobileMenuItem({
return (
<div
className={cn(
"inline-flex h-10 w-full cursor-pointer items-center whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium text-primary-variant disabled:pointer-events-none disabled:opacity-50",
"inline-flex h-10 w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md px-4 py-2 pr-2 text-sm font-medium text-primary-variant disabled:pointer-events-none disabled:opacity-50",
className,
)}
onClick={() => {
@ -605,6 +600,7 @@ function MobileMenuItem({
<div className="w-full">
{label ?? <div>{t("menu." + item.key)}</div>}
</div>
<LuChevronRight className="size-4" />
</div>
);
}
@ -617,39 +613,6 @@ export default function Settings() {
const [sectionStatusByKey, setSectionStatusByKey] = useState<
Partial<Record<SettingsType, SectionStatus>>
>({});
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
() =>
// all collapsed by default
new Set(
settingsGroups.filter((g) => g.items.length > 1).map((g) => g.label),
),
);
const toggleGroupCollapsed = useCallback((label: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(label)) {
next.delete(label);
} else {
next.add(label);
}
return next;
});
}, []);
// Auto-expand the group containing the active page whenever pageToggle changes
useEffect(() => {
const containingGroup = settingsGroups.find((group) =>
group.items.some((item) => item.key === pageToggle),
);
if (!containingGroup) return;
setCollapsedGroups((prev) => {
if (!prev.has(containingGroup.label)) return prev;
const next = new Set(prev);
next.delete(containingGroup.label);
return next;
});
}, [pageToggle]);
const { data: config } = useSWR<FrigateConfig>("config");
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
@ -1648,49 +1611,34 @@ export default function Settings() {
visibleSettingsViews.includes(item.key as SettingsType),
);
if (filteredItems.length === 0) return null;
const isMultiItem = filteredItems.length > 1;
const renderedExpanded =
!isMultiItem || !collapsedGroups.has(group.label);
const items = filteredItems.map((item) => (
<MobileMenuItem
key={item.key}
item={item}
className={cn(filteredItems.length == 1 && "pl-2")}
label={renderMenuItemLabel(item.key as SettingsType)}
onSelect={(key) => {
if (
!isAdmin &&
!ALLOWED_VIEWS_FOR_VIEWER.includes(key as SettingsType)
) {
setPageToggle("uiSettings");
} else {
setPageToggle(key as SettingsType);
}
setContentMobileOpen(true);
}}
/>
));
return (
<div key={group.label} className="mb-3">
{isMultiItem ? (
<Collapsible
open={renderedExpanded}
onOpenChange={() => toggleGroupCollapsed(group.label)}
>
<CollapsibleTrigger className="flex min-h-10 w-full items-center justify-between rounded-md py-2 pl-2 pr-2 text-sm font-medium text-secondary-foreground">
<div>{t("menu." + group.label)}</div>
<LuChevronRight
className={cn(
"size-4 shrink-0 transition-transform duration-200",
renderedExpanded && "rotate-90",
)}
/>
</CollapsibleTrigger>
<CollapsibleContent>{items}</CollapsibleContent>
</Collapsible>
) : (
items
{filteredItems.length > 1 && (
<h3 className="mb-2 ml-2 text-sm font-medium text-secondary-foreground">
<div>{t("menu." + group.label)}</div>
</h3>
)}
{filteredItems.map((item) => (
<MobileMenuItem
key={item.key}
item={item}
className={cn(filteredItems.length == 1 && "pl-2")}
label={renderMenuItemLabel(item.key as SettingsType)}
onSelect={(key) => {
if (
!isAdmin &&
!ALLOWED_VIEWS_FOR_VIEWER.includes(
key as SettingsType,
)
) {
setPageToggle("uiSettings");
} else {
setPageToggle(key as SettingsType);
}
setContentMobileOpen(true);
}}
/>
))}
</div>
);
})}
@ -1992,74 +1940,48 @@ export default function Settings() {
</SidebarMenuItem>
</SidebarMenu>
) : (
(() => {
const hasActiveItem = filteredItems.some(
(item) => pageToggle === item.key,
);
const renderedExpanded = !collapsedGroups.has(
group.label,
);
return (
<Collapsible
open={renderedExpanded}
onOpenChange={() =>
toggleGroupCollapsed(group.label)
}
>
<SidebarGroupLabel
asChild
className={cn(
"ml-2 pl-0 text-sm",
hasActiveItem
? "text-primary"
: "text-sidebar-foreground/80",
)}
>
<CollapsibleTrigger className="flex w-full items-center justify-between">
<div>{t("menu." + group.label)}</div>
<LuChevronRight
className={cn(
"size-4 shrink-0 transition-transform duration-200",
renderedExpanded && "rotate-90",
<>
<SidebarGroupLabel
className={cn(
"ml-2 cursor-default pl-0 text-sm",
filteredItems.some(
(item) => pageToggle === item.key,
)
? "text-primary"
: "text-sidebar-foreground/80",
)}
>
<div>{t("menu." + group.label)}</div>
</SidebarGroupLabel>
<SidebarMenuSub className="mx-2 border-0">
{filteredItems.map((item) => (
<SidebarMenuSubItem key={item.key}>
<SidebarMenuSubButton
className="h-auto w-full py-1.5"
isActive={pageToggle === item.key}
onClick={() => {
if (
!isAdmin &&
!ALLOWED_VIEWS_FOR_VIEWER.includes(
item.key as SettingsType,
)
) {
setPageToggle("uiSettings");
} else {
setPageToggle(item.key as SettingsType);
}
}}
>
<div className="w-full cursor-pointer">
{renderMenuItemLabel(
item.key as SettingsType,
)}
/>
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarMenuSub className="mx-2 border-0 md:mx-0">
{filteredItems.map((item) => (
<SidebarMenuSubItem key={item.key}>
<SidebarMenuSubButton
className="h-auto w-full py-1.5"
isActive={pageToggle === item.key}
onClick={() => {
if (
!isAdmin &&
!ALLOWED_VIEWS_FOR_VIEWER.includes(
item.key as SettingsType,
)
) {
setPageToggle("uiSettings");
} else {
setPageToggle(
item.key as SettingsType,
);
}
}}
>
<div className="w-full cursor-pointer">
{renderMenuItemLabel(
item.key as SettingsType,
)}
</div>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
);
})()
</div>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</>
)}
</SidebarGroup>
);

View File

@ -66,7 +66,6 @@ import SummaryTimeline from "@/components/timeline/SummaryTimeline";
import { RecordingStartingPoint } from "@/types/record";
import VideoControls from "@/components/player/VideoControls";
import { TimeRange } from "@/types/timeline";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import {
useCameraMotionNextTimestamp,
useCameraMotionOnlyRanges,
@ -1009,29 +1008,27 @@ function MotionReview({
const { t } = useTranslation(["views/events", "common"]);
const segmentDuration = 30;
const { data: config } = useSWR<FrigateConfig>("config");
const allowedCameras = useAllowedCameras();
const reviewCameras = useMemo(() => {
if (!config) {
return [];
}
const selectedCams = filter?.cameras;
const cameras = Object.values(config.cameras).filter((cam) => {
if (isReplayCamera(cam.name)) {
return false;
}
if (!allowedCameras.includes(cam.name)) {
return false;
}
if (selectedCams && !selectedCams.includes(cam.name)) {
return false;
}
return true;
});
let cameras;
if (!filter || !filter.cameras) {
cameras = Object.values(config.cameras).filter(
(cam) => !isReplayCamera(cam.name),
);
} else {
const filteredCams = filter.cameras;
cameras = Object.values(config.cameras).filter(
(cam) => filteredCams.includes(cam.name) && !isReplayCamera(cam.name),
);
}
return cameras.sort((a, b) => a.ui.order - b.ui.order);
}, [config, filter, allowedCameras]);
}, [config, filter]);
const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({});

View File

@ -13,7 +13,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import {
AllGroupsStreamingSettings,
@ -91,7 +90,6 @@ export default function LiveDashboardView({
// recent events
const eventUpdate = useFrigateReviews();
const allowedCameras = useAllowedCameras();
const alertCameras = useMemo(() => {
if (!config) {
@ -100,16 +98,14 @@ export default function LiveDashboardView({
if (cameraGroup == "default") {
return Object.values(config.cameras)
.filter((cam) => cam.ui.dashboard && allowedCameras.includes(cam.name))
.filter((cam) => cam.ui.dashboard)
.map((cam) => cam.name)
.join(",");
}
if (includeBirdseye && cameras.length == 0) {
return Object.values(config.cameras)
.filter(
(cam) => cam.birdseye.enabled && allowedCameras.includes(cam.name),
)
.filter((cam) => cam.birdseye.enabled)
.map((cam) => cam.name)
.join(",");
}
@ -118,7 +114,7 @@ export default function LiveDashboardView({
.map((cam) => cam.name)
.filter((cam) => config.camera_groups[cameraGroup]?.cameras.includes(cam))
.join(",");
}, [cameras, cameraGroup, config, includeBirdseye, allowedCameras]);
}, [cameras, cameraGroup, config, includeBirdseye]);
const { data: allEvents, mutate: updateEvents } = useSWR<ReviewSegment[]>([
"review",

View File

@ -44,7 +44,6 @@ import {
import { IoMdArrowRoundBack } from "react-icons/io";
import { useLocation, useNavigate } from "react-router-dom";
import { Toaster } from "@/components/ui/sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
import useSWR from "swr";
import { TimeRange, TimelineType } from "@/types/timeline";
import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer";
@ -110,7 +109,6 @@ export function RecordingView({
}: RecordingViewProps) {
const { t } = useTranslation(["views/events", "components/dialog"]);
const { data: config } = useSWR<FrigateConfig>("config");
const isAdmin = useIsAdmin();
const navigate = useNavigate();
const location = useLocation();
const contentRef = useRef<HTMLDivElement | null>(null);
@ -725,17 +723,13 @@ export function RecordingView({
setCustomShareTimestamp(initialTimestamp);
setShareTimestampOpen(true);
}}
onDebugReplayClick={
isAdmin
? () => {
setDebugReplayRange({
after: timeRange.before - 60,
before: timeRange.before,
});
setDebugReplayMode("select");
}
: undefined
}
onDebugReplayClick={() => {
setDebugReplayRange({
after: timeRange.before - 60,
before: timeRange.before,
});
setDebugReplayMode("select");
}}
onExportClick={() => {
const now = new Date(timeRange.before * 1000);
now.setHours(now.getHours() - 1);