mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
Compare commits
1 Commits
dbb93ddd17
...
37daec8610
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37daec8610 |
@ -5,8 +5,7 @@ title: Installation
|
|||||||
|
|
||||||
import ShmCalculator from '@site/src/components/ShmCalculator'
|
import ShmCalculator from '@site/src/components/ShmCalculator'
|
||||||
import DockerComposeGenerator from '@site/src/components/DockerComposeGenerator'
|
import DockerComposeGenerator from '@site/src/components/DockerComposeGenerator'
|
||||||
import Tabs from '@theme/Tabs';
|
|
||||||
import TabItem from '@theme/TabItem';
|
|
||||||
|
|
||||||
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App.
|
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App.
|
||||||
|
|
||||||
@ -74,6 +73,13 @@ Users of the Snapcraft build of Docker cannot use storage locations outside your
|
|||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
### Docker Compose Generator
|
||||||
|
|
||||||
|
Generate a Frigate Docker Compose configuration based on your hardware and requirements.
|
||||||
|
|
||||||
|
<DockerComposeGenerator/>
|
||||||
|
|
||||||
|
|
||||||
### Calculating required shm-size
|
### Calculating required shm-size
|
||||||
|
|
||||||
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**.
|
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**.
|
||||||
@ -471,16 +477,6 @@ Finally, configure [hardware object detection](/configuration/object_detectors#a
|
|||||||
|
|
||||||
Running through Docker with Docker Compose is the recommended install method.
|
Running through Docker with Docker Compose is the recommended install method.
|
||||||
|
|
||||||
<Tabs>
|
|
||||||
<TabItem value="domestic" label="Docker Compose Generator" default>
|
|
||||||
|
|
||||||
Generate a Frigate Docker Compose configuration based on your hardware and requirements.
|
|
||||||
|
|
||||||
<DockerComposeGenerator/>
|
|
||||||
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="original" label="Example Docker Compose File">
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
frigate:
|
frigate:
|
||||||
@ -514,10 +510,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
FRIGATE_RTSP_PASSWORD: "password"
|
FRIGATE_RTSP_PASSWORD: "password"
|
||||||
```
|
```
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
**Docker CLI**
|
|
||||||
|
|
||||||
If you can't use Docker Compose, you can run the container with something similar to this:
|
If you can't use Docker Compose, you can run the container with something similar to this:
|
||||||
|
|
||||||
|
|||||||
@ -32,12 +32,12 @@ function renderHelpText(text: string): React.ReactNode {
|
|||||||
export default function DockerComposeGenerator() {
|
export default function DockerComposeGenerator() {
|
||||||
const {
|
const {
|
||||||
deviceId, device, hardwareEnabled,
|
deviceId, device, hardwareEnabled,
|
||||||
portEnabled,
|
portEnabled, port5000Confirmed,
|
||||||
nvidiaGpuCount, nvidiaGpuDeviceId,
|
nvidiaGpuCount, nvidiaGpuDeviceId,
|
||||||
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
||||||
shmSizeError, gpuDeviceIdError, configPathError, mediaPathError,
|
shmSizeError, gpuDeviceIdError, configPathError, mediaPathError,
|
||||||
hasAnyHardware, generatedYaml,
|
hasAnyHardware, generatedYaml,
|
||||||
selectDevice, toggleHardware, togglePort,
|
selectDevice, toggleHardware, togglePort, setPort5000Confirmed,
|
||||||
handleShmSizeChange, handleConfigPathChange, handleMediaPathChange,
|
handleShmSizeChange, handleConfigPathChange, handleMediaPathChange,
|
||||||
handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange,
|
handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange,
|
||||||
setRtspPassword, setTimezone, isHardwareDisabled,
|
setRtspPassword, setTimezone, isHardwareDisabled,
|
||||||
@ -82,7 +82,9 @@ export default function DockerComposeGenerator() {
|
|||||||
|
|
||||||
<PortConfigSection
|
<PortConfigSection
|
||||||
portEnabled={portEnabled}
|
portEnabled={portEnabled}
|
||||||
|
port5000Confirmed={port5000Confirmed}
|
||||||
onTogglePort={togglePort}
|
onTogglePort={togglePort}
|
||||||
|
onConfirm5000={setPort5000Confirmed}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OtherOptions
|
<OtherOptions
|
||||||
|
|||||||
@ -16,8 +16,6 @@ export default function NvidiaGpuConfig({
|
|||||||
onGpuCountChange,
|
onGpuCountChange,
|
||||||
onGpuDeviceIdChange,
|
onGpuDeviceIdChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const showDeviceId = gpuCount !== "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.nvidiaConfig}>
|
<div className={styles.nvidiaConfig}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
@ -27,15 +25,16 @@ export default function NvidiaGpuConfig({
|
|||||||
<input
|
<input
|
||||||
id="dcg-gpu-count"
|
id="dcg-gpu-count"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
value={gpuCount}
|
value={gpuCount}
|
||||||
placeholder="all"
|
placeholder="1 or all"
|
||||||
onChange={(e) => onGpuCountChange(e.target.value.replace(/\D/g, ""))}
|
onChange={(e) => onGpuCountChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<p className={styles.helpText}>
|
||||||
|
Enter a number (e.g. 1, 2, 3) or "all" to use all GPUs
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{showDeviceId && (
|
{gpuCount !== "all" && (
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label htmlFor="dcg-gpu-device-id" className={styles.label}>
|
<label htmlFor="dcg-gpu-device-id" className={styles.label}>
|
||||||
GPU device IDs (required, comma-separated):
|
GPU device IDs (required, comma-separated):
|
||||||
|
|||||||
@ -1,34 +1,103 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Admonition from "@theme/Admonition";
|
import Admonition from "@theme/Admonition";
|
||||||
import { ports } from "../config";
|
import { ports } from "../config";
|
||||||
|
import { useCooldown } from "../hooks/useCooldown";
|
||||||
import styles from "../styles.module.css";
|
import styles from "../styles.module.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
portEnabled: Record<string, boolean>;
|
portEnabled: Record<string, boolean>;
|
||||||
|
port5000Confirmed: boolean;
|
||||||
onTogglePort: (portId: string) => void;
|
onTogglePort: (portId: string) => void;
|
||||||
|
onConfirm5000: (confirmed: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PortItem({
|
function Port5000Confirmation({
|
||||||
port,
|
portEnabled,
|
||||||
enabled,
|
confirmed,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onConfirm,
|
||||||
}: {
|
}: {
|
||||||
port: typeof ports[number];
|
portEnabled: boolean;
|
||||||
enabled: boolean;
|
confirmed: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
onConfirm: (confirmed: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const showWarning = port.warningContent && (
|
const { remaining, start, stop } = useCooldown(10);
|
||||||
port.warningWhen === "checked" ? enabled :
|
|
||||||
port.warningWhen === "unchecked" ? !enabled : enabled
|
React.useEffect(() => {
|
||||||
);
|
if (portEnabled) {
|
||||||
|
start();
|
||||||
|
} else {
|
||||||
|
stop();
|
||||||
|
onConfirm(false);
|
||||||
|
}
|
||||||
|
return stop;
|
||||||
|
}, [portEnabled]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.hardwareItem}>
|
<div className={styles.portSection}>
|
||||||
|
{portEnabled && (
|
||||||
|
<Admonition type="danger">
|
||||||
|
<p>
|
||||||
|
Exposing port 5000 allows <strong>unauthenticated access</strong> to
|
||||||
|
your Frigate instance. Anyone on your network (or the internet if you
|
||||||
|
have a public IP) could access it without credentials.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This may lead to <strong>unauthorized access</strong>,{" "}
|
||||||
|
<strong>privacy leaks</strong>, or further attacks. Ensure you have
|
||||||
|
proper firewall rules or VPN in place.
|
||||||
|
</p>
|
||||||
|
<label
|
||||||
|
className={`${styles.checkboxLabel} ${remaining > 0 ? styles.checkboxDisabled : ""}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={confirmed}
|
||||||
|
onChange={(e) => onConfirm(e.target.checked)}
|
||||||
|
disabled={remaining > 0}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
I understand the risk and confirm enabling port 5000
|
||||||
|
{remaining > 0 && ` (${remaining}s)`}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</Admonition>
|
||||||
|
)}
|
||||||
|
<label className={styles.checkboxLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={portEnabled}
|
||||||
|
onChange={onToggle}
|
||||||
|
/>
|
||||||
|
<span>Port 5000 (unauthenticated access)</span>
|
||||||
|
<span className={styles.warningBadge}>⚠️ Expose carefully</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortConfigSection({
|
||||||
|
portEnabled,
|
||||||
|
port5000Confirmed,
|
||||||
|
onTogglePort,
|
||||||
|
onConfirm5000,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className={styles.formSection}>
|
||||||
|
<h4>Port Configuration</h4>
|
||||||
|
|
||||||
|
{/* All ports except 5000 */}
|
||||||
|
<div className={styles.checkboxGrid}>
|
||||||
|
{ports
|
||||||
|
.filter((p) => p.id !== "5000")
|
||||||
|
.map((port) => (
|
||||||
|
<div key={port.id} className={styles.hardwareItem}>
|
||||||
<label className={`${styles.checkboxLabel} ${port.locked ? styles.checkboxDisabled : ""}`}>
|
<label className={`${styles.checkboxLabel} ${port.locked ? styles.checkboxDisabled : ""}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={enabled}
|
checked={!!portEnabled[port.id]}
|
||||||
onChange={onToggle}
|
onChange={() => onTogglePort(port.id)}
|
||||||
disabled={port.locked}
|
disabled={port.locked}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
@ -40,32 +109,17 @@ function PortItem({
|
|||||||
{port.description && (
|
{port.description && (
|
||||||
<div className={styles.hardwareDescription}>{port.description}</div>
|
<div className={styles.hardwareDescription}>{port.description}</div>
|
||||||
)}
|
)}
|
||||||
{showWarning && (
|
|
||||||
<Admonition type={port.warningType || "warning"}>
|
|
||||||
{port.warningContent}
|
|
||||||
</Admonition>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PortConfigSection({
|
|
||||||
portEnabled,
|
|
||||||
onTogglePort,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
|
||||||
<div className={styles.formSection}>
|
|
||||||
<h4>Port Configuration</h4>
|
|
||||||
<div className={styles.checkboxGrid}>
|
|
||||||
{ports.map((port) => (
|
|
||||||
<PortItem
|
|
||||||
key={port.id}
|
|
||||||
port={port}
|
|
||||||
enabled={!!portEnabled[port.id]}
|
|
||||||
onToggle={() => onTogglePort(port.id)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Port 5000 with special warning — placed last */}
|
||||||
|
<Port5000Confirmation
|
||||||
|
portEnabled={!!portEnabled["5000"]}
|
||||||
|
confirmed={port5000Confirmed}
|
||||||
|
onToggle={() => onTogglePort("5000")}
|
||||||
|
onConfirm={onConfirm5000}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -265,8 +265,7 @@ ports:
|
|||||||
protocol: "tcp"
|
protocol: "tcp"
|
||||||
description: "Authenticated UI and API access (default HTTPS)"
|
description: "Authenticated UI and API access (default HTTPS)"
|
||||||
defaultEnabled: true
|
defaultEnabled: true
|
||||||
warningContent: "This is the access port for Frigate. Closing it means you will no longer be able to access the instance."
|
locked: true
|
||||||
warningWhen: "unchecked"
|
|
||||||
|
|
||||||
- id: "8554"
|
- id: "8554"
|
||||||
host: 8554
|
host: 8554
|
||||||
|
|||||||
@ -145,10 +145,14 @@ export interface PortConfig {
|
|||||||
defaultEnabled: boolean;
|
defaultEnabled: boolean;
|
||||||
/** Whether this port is locked (always enabled, cannot be toggled off) */
|
/** Whether this port is locked (always enabled, cannot be toggled off) */
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
|
/** Whether this port requires a confirmation step before enabling */
|
||||||
|
requiresConfirmation?: boolean;
|
||||||
/** Admonition type for the warning */
|
/** Admonition type for the warning */
|
||||||
warningType?: "warning" | "danger";
|
warningType?: "warning" | "danger";
|
||||||
/** Warning content (markdown) */
|
/** Warning content (markdown) */
|
||||||
warningContent?: string;
|
warningContent?: string;
|
||||||
/** When to show the warning: when the port is checked or unchecked */
|
/** Confirmation checkbox label */
|
||||||
warningWhen?: "checked" | "unchecked";
|
confirmationLabel?: string;
|
||||||
|
/** Cooldown in seconds before the confirmation checkbox becomes available */
|
||||||
|
cooldownSeconds?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -123,7 +123,7 @@ function buildEnvironment(
|
|||||||
function buildDeploy(device: DeviceConfig, input: GeneratorInput): string[] {
|
function buildDeploy(device: DeviceConfig, input: GeneratorInput): string[] {
|
||||||
if (device.id === "stable-tensorrt") {
|
if (device.id === "stable-tensorrt") {
|
||||||
const count = input.nvidiaGpuCount || "all";
|
const count = input.nvidiaGpuCount || "all";
|
||||||
const isAll = count === "all";
|
const isAll = count.toLowerCase() === "all";
|
||||||
const deviceId = input.nvidiaGpuDeviceId?.trim();
|
const deviceId = input.nvidiaGpuDeviceId?.trim();
|
||||||
|
|
||||||
if (isAll) {
|
if (isAll) {
|
||||||
|
|||||||
@ -29,7 +29,8 @@ export function useConfigGenerator() {
|
|||||||
return initial;
|
return initial;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [nvidiaGpuCount, setNvidiaGpuCount] = useState("");
|
const [port5000Confirmed, setPort5000Confirmed] = useState(false);
|
||||||
|
const [nvidiaGpuCount, setNvidiaGpuCount] = useState("all");
|
||||||
const [nvidiaGpuDeviceId, setNvidiaGpuDeviceId] = useState("");
|
const [nvidiaGpuDeviceId, setNvidiaGpuDeviceId] = useState("");
|
||||||
const [configPath, setConfigPath] = useState("");
|
const [configPath, setConfigPath] = useState("");
|
||||||
const [mediaPath, setMediaPath] = useState("");
|
const [mediaPath, setMediaPath] = useState("");
|
||||||
@ -56,7 +57,7 @@ export function useConfigGenerator() {
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setNvidiaGpuCount("");
|
setNvidiaGpuCount("all");
|
||||||
setNvidiaGpuDeviceId("");
|
setNvidiaGpuDeviceId("");
|
||||||
setGpuDeviceIdError(false);
|
setGpuDeviceIdError(false);
|
||||||
}, []);
|
}, []);
|
||||||
@ -68,7 +69,13 @@ export function useConfigGenerator() {
|
|||||||
const togglePort = useCallback((portId: string) => {
|
const togglePort = useCallback((portId: string) => {
|
||||||
const port = portMap.get(portId);
|
const port = portMap.get(portId);
|
||||||
if (port?.locked) return;
|
if (port?.locked) return;
|
||||||
setPortEnabled((prev) => ({ ...prev, [portId]: !prev[portId] }));
|
setPortEnabled((prev) => {
|
||||||
|
const next = { ...prev, [portId]: !prev[portId] };
|
||||||
|
if (portId === "5000" && !next[portId]) {
|
||||||
|
setPort5000Confirmed(false);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isHardwareDisabled = useCallback(
|
const isHardwareDisabled = useCallback(
|
||||||
@ -121,14 +128,16 @@ export function useConfigGenerator() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleNvidiaGpuCountChange = useCallback((value: string) => {
|
const handleNvidiaGpuCountChange = useCallback((value: string) => {
|
||||||
// Only allow digits
|
const lower = value.trim().toLowerCase();
|
||||||
setNvidiaGpuCount(value);
|
if (lower === "all" || lower === "" || /^[0-9]+$/.test(lower)) {
|
||||||
if (value === "") {
|
setNvidiaGpuCount(lower || "all");
|
||||||
|
if (lower === "all") {
|
||||||
setNvidiaGpuDeviceId("");
|
setNvidiaGpuDeviceId("");
|
||||||
setGpuDeviceIdError(false);
|
setGpuDeviceIdError(false);
|
||||||
} else {
|
} else if (/^[0-9]+$/.test(lower)) {
|
||||||
setGpuDeviceIdError(false);
|
setGpuDeviceIdError(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNvidiaGpuDeviceIdChange = useCallback((value: string) => {
|
const handleNvidiaGpuDeviceIdChange = useCallback((value: string) => {
|
||||||
@ -140,6 +149,7 @@ export function useConfigGenerator() {
|
|||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
for (const [id, enabled] of Object.entries(portEnabled)) {
|
for (const [id, enabled] of Object.entries(portEnabled)) {
|
||||||
if (!enabled) continue;
|
if (!enabled) continue;
|
||||||
|
if (id === "5000" && !port5000Confirmed) continue;
|
||||||
const p = portMap.get(id);
|
const p = portMap.get(id);
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
const proto = p.protocol && p.protocol !== "tcp" ? `/${p.protocol}` : "";
|
const proto = p.protocol && p.protocol !== "tcp" ? `/${p.protocol}` : "";
|
||||||
@ -147,7 +157,7 @@ export function useConfigGenerator() {
|
|||||||
lines.push(` - "${p.host}:${p.container}${proto}"${comment}`);
|
lines.push(` - "${p.host}:${p.container}${proto}"${comment}`);
|
||||||
}
|
}
|
||||||
return lines;
|
return lines;
|
||||||
}, [portEnabled]);
|
}, [portEnabled, port5000Confirmed]);
|
||||||
|
|
||||||
const selectedHardwareIds = useMemo(() => {
|
const selectedHardwareIds = useMemo(() => {
|
||||||
return Object.entries(hardwareEnabled)
|
return Object.entries(hardwareEnabled)
|
||||||
@ -185,11 +195,11 @@ export function useConfigGenerator() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
deviceId, device, hardwareEnabled, portEnabled,
|
deviceId, device, hardwareEnabled, portEnabled,
|
||||||
nvidiaGpuCount, nvidiaGpuDeviceId,
|
port5000Confirmed, nvidiaGpuCount, nvidiaGpuDeviceId,
|
||||||
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
||||||
shmSizeError, gpuDeviceIdError, configPathError, mediaPathError,
|
shmSizeError, gpuDeviceIdError, configPathError, mediaPathError,
|
||||||
hasAnyHardware, generatedYaml,
|
hasAnyHardware, generatedYaml,
|
||||||
selectDevice, toggleHardware, togglePort,
|
selectDevice, toggleHardware, togglePort, setPort5000Confirmed,
|
||||||
handleShmSizeChange, handleConfigPathChange, handleMediaPathChange,
|
handleShmSizeChange, handleConfigPathChange, handleMediaPathChange,
|
||||||
handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange,
|
handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange,
|
||||||
setRtspPassword, setTimezone, isHardwareDisabled,
|
setRtspPassword, setTimezone, isHardwareDisabled,
|
||||||
|
|||||||
@ -0,0 +1,42 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for a countdown timer (e.g. cooldown before confirming port 5000).
|
||||||
|
*/
|
||||||
|
export function useCooldown(initialSeconds: number) {
|
||||||
|
const [remaining, setRemaining] = useState(0);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
// Clear any existing timer
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
setRemaining(initialSeconds);
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setRemaining((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}, [initialSeconds]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
setRemaining(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { remaining, start, stop };
|
||||||
|
}
|
||||||
@ -5,15 +5,13 @@ import logging
|
|||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
import time
|
import time
|
||||||
import zipfile
|
|
||||||
from collections import deque
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterator, List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pathvalidate import sanitize_filename, sanitize_filepath
|
from pathvalidate import sanitize_filepath
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
@ -363,136 +361,6 @@ def get_export_case(case_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_ZIP_STREAM_CHUNK_SIZE = 1024 * 1024 # 1 MiB
|
|
||||||
|
|
||||||
|
|
||||||
class _StreamingZipBuffer:
|
|
||||||
"""File-like sink for ZipFile that exposes written bytes via drain().
|
|
||||||
|
|
||||||
ZipFile writes synchronously into this buffer; the generator drains the
|
|
||||||
queue between writes so StreamingResponse can yield bytes without
|
|
||||||
materializing the whole archive in memory.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._queue: deque[bytes] = deque()
|
|
||||||
self._offset = 0
|
|
||||||
|
|
||||||
def write(self, data: bytes) -> int:
|
|
||||||
if data:
|
|
||||||
self._queue.append(bytes(data))
|
|
||||||
self._offset += len(data)
|
|
||||||
return len(data)
|
|
||||||
|
|
||||||
def tell(self) -> int:
|
|
||||||
return self._offset
|
|
||||||
|
|
||||||
def flush(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def drain(self) -> Iterator[bytes]:
|
|
||||||
while self._queue:
|
|
||||||
yield self._queue.popleft()
|
|
||||||
|
|
||||||
|
|
||||||
def _unique_archive_name(export: Export, used: set[str]) -> str:
|
|
||||||
base = sanitize_filename(export.name) if export.name else None
|
|
||||||
if not base:
|
|
||||||
base = f"{export.camera}_{int(datetime.datetime.timestamp(export.date))}"
|
|
||||||
|
|
||||||
candidate = f"{base}.mp4"
|
|
||||||
counter = 1
|
|
||||||
while candidate in used:
|
|
||||||
candidate = f"{base}_{counter}.mp4"
|
|
||||||
counter += 1
|
|
||||||
|
|
||||||
used.add(candidate)
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def _stream_case_archive(exports: List[Export]) -> Iterator[bytes]:
|
|
||||||
"""Yield bytes of a zip archive built from the given exports' mp4 files."""
|
|
||||||
buffer = _StreamingZipBuffer()
|
|
||||||
used_names: set[str] = set()
|
|
||||||
|
|
||||||
# ZIP_STORED: mp4 is already compressed, recompressing wastes CPU for ~0% size win.
|
|
||||||
with zipfile.ZipFile(
|
|
||||||
buffer,
|
|
||||||
mode="w",
|
|
||||||
compression=zipfile.ZIP_STORED,
|
|
||||||
allowZip64=True,
|
|
||||||
) as archive:
|
|
||||||
for export in exports:
|
|
||||||
source = Path(export.video_path)
|
|
||||||
if not source.exists():
|
|
||||||
continue
|
|
||||||
|
|
||||||
arcname = _unique_archive_name(export, used_names)
|
|
||||||
|
|
||||||
with (
|
|
||||||
archive.open(arcname, mode="w", force_zip64=True) as entry,
|
|
||||||
source.open("rb") as src,
|
|
||||||
):
|
|
||||||
while True:
|
|
||||||
chunk = src.read(_ZIP_STREAM_CHUNK_SIZE)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
|
|
||||||
entry.write(chunk)
|
|
||||||
yield from buffer.drain()
|
|
||||||
|
|
||||||
yield from buffer.drain()
|
|
||||||
|
|
||||||
yield from buffer.drain()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/cases/{case_id}/download",
|
|
||||||
dependencies=[Depends(allow_any_authenticated())],
|
|
||||||
summary="Download export case as zip",
|
|
||||||
description="Streams a zip archive containing every completed export's mp4 for the given case.",
|
|
||||||
)
|
|
||||||
def download_export_case(
|
|
||||||
case_id: str,
|
|
||||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
case = ExportCase.get(ExportCase.id == case_id)
|
|
||||||
except DoesNotExist:
|
|
||||||
return JSONResponse(
|
|
||||||
content={"success": False, "message": "Export case not found"},
|
|
||||||
status_code=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
exports = list(
|
|
||||||
Export.select()
|
|
||||||
.where(
|
|
||||||
Export.export_case == case_id,
|
|
||||||
~Export.in_progress,
|
|
||||||
Export.camera << allowed_cameras,
|
|
||||||
)
|
|
||||||
.order_by(Export.date.asc())
|
|
||||||
)
|
|
||||||
|
|
||||||
if not exports:
|
|
||||||
return JSONResponse(
|
|
||||||
content={"success": False, "message": "No exports available to download."},
|
|
||||||
status_code=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
archive_base = sanitize_filename(case.name) if case.name else ""
|
|
||||||
if not archive_base:
|
|
||||||
archive_base = case_id
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
_stream_case_archive(exports),
|
|
||||||
media_type="application/zip",
|
|
||||||
headers={
|
|
||||||
"Content-Disposition": f'attachment; filename="{archive_base}.zip"',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/cases/{case_id}",
|
"/cases/{case_id}",
|
||||||
response_model=GenericResponse,
|
response_model=GenericResponse,
|
||||||
|
|||||||
@ -57,7 +57,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
import {
|
import {
|
||||||
LuDownload,
|
|
||||||
LuFolderPlus,
|
LuFolderPlus,
|
||||||
LuFolderX,
|
LuFolderX,
|
||||||
LuPencil,
|
LuPencil,
|
||||||
@ -778,29 +777,8 @@ function Exports() {
|
|||||||
filters={["cameras"]}
|
filters={["cameras"]}
|
||||||
onUpdateFilter={setExportFilter}
|
onUpdateFilter={setExportFilter}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1 md:gap-2">
|
|
||||||
{(exportsByCase[selectedCase.id]?.length ?? 0) > 0 && (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
className="flex items-center gap-2 p-2"
|
|
||||||
size="sm"
|
|
||||||
aria-label={t("button.download", { ns: "common" })}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
download
|
|
||||||
href={`${baseUrl}api/cases/${selectedCase.id}/download`}
|
|
||||||
>
|
|
||||||
<LuDownload className="text-secondary-foreground" />
|
|
||||||
{!isMobile && (
|
|
||||||
<div className="text-primary">
|
|
||||||
{t("button.download", { ns: "common" })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<div className="flex items-center gap-1 md:gap-2">
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-2 p-2"
|
className="flex items-center gap-2 p-2"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -845,9 +823,8 @@ function Exports() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user