Compare commits

..

14 Commits

Author SHA1 Message Date
GuoQing Liu
39fd108334
Merge 9e72a825f3 into ba4a6a53d7 2026-05-01 08:24:34 -06:00
ZhaiSoul
9e72a825f3
docs: RTSP password is optional 2026-05-01 22:24:27 +08:00
ZhaiSoul
bb5e01ac45
docs: add hailo and memryX mx3 driver tips 2026-05-01 22:19:53 +08:00
ZhaiSoul
43e90d6de4
docs: timezone change to select 2026-05-01 22:13:00 +08:00
ZhaiSoul
b893a4f851
docs: Adjust the position of the RTSP password variable option 2026-05-01 21:59:19 +08:00
GuoQing Liu
1b0816db87
Update docs/src/components/DockerComposeGenerator/config/config.yaml
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-05-01 21:52:41 +08:00
GuoQing Liu
4b81a82a32
Update docs/src/components/DockerComposeGenerator/components/StoragePaths.tsx
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-05-01 21:52:32 +08:00
GuoQing Liu
feda1c791c
Update docs/src/components/DockerComposeGenerator/components/StoragePaths.tsx
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-05-01 21:52:25 +08:00
GuoQing Liu
f75e9c30d0
Update docs/src/components/DockerComposeGenerator/config/config.yaml
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-05-01 21:52:15 +08:00
GuoQing Liu
8c9259a973
Update docs/src/components/DockerComposeGenerator/config/config.yaml
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-05-01 21:52:06 +08:00
GuoQing Liu
1df11f3ddd
Update docs/src/components/DockerComposeGenerator/components/OtherOptions.tsx
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-05-01 21:51:58 +08:00
GuoQing Liu
845a813651
Update docs/src/components/DockerComposeGenerator/config/config.yaml
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-05-01 21:51:44 +08:00
Josh Hawkins
ba4a6a53d7
Miscellaneous fixes (#23053)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* don't exempt draft PRs from stalebot

* Fix import

* ensure toast shows when export API returns 20n (202, accepted)

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-04-30 17:19:53 -06:00
Nicolas Mowen
e90079ab2f
Include chapters for review items in exports (#23052) 2026-04-30 18:16:24 -05:00
12 changed files with 248 additions and 78 deletions

View File

@ -18,7 +18,7 @@ jobs:
close-issue-message: "" close-issue-message: ""
days-before-stale: 30 days-before-stale: 30
days-before-close: 3 days-before-close: 3
exempt-draft-pr: true exempt-draft-pr: false
exempt-issue-labels: "planned,security" exempt-issue-labels: "planned,security"
exempt-pr-labels: "planned,security,dependencies" exempt-pr-labels: "planned,security,dependencies"
operations-per-run: 120 operations-per-run: 120

View File

@ -1,7 +1,24 @@
import React from "react"; import React, { useMemo } from "react";
import CodeInline from "@theme/CodeInline"; import CodeInline from "@theme/CodeInline";
import styles from "../styles.module.css"; import styles from "../styles.module.css";
const AUTO_TIMEZONE_VALUE = "__auto__";
function getTimezoneList(): string[] {
if (typeof Intl !== "undefined") {
const intl = Intl as typeof Intl & {
supportedValuesOf?: (key: string) => string[];
};
const supported = intl.supportedValuesOf?.("timeZone");
if (supported && supported.length > 0) {
return [...supported].sort();
}
}
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone;
return fallback ? [fallback] : ["UTC"];
}
interface Props { interface Props {
rtspPassword: string; rtspPassword: string;
timezone: string; timezone: string;
@ -21,41 +38,38 @@ export default function OtherOptions({
onTimezoneChange, onTimezoneChange,
onShmSizeChange, onShmSizeChange,
}: Props) { }: Props) {
const timezones = useMemo(() => getTimezoneList(), []);
const systemTimezone =
Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC";
const selectedValue = timezone || AUTO_TIMEZONE_VALUE;
return ( return (
<div className={styles.formSection}> <div className={styles.formSection}>
<h4>Other Options</h4> <h4>Other Options</h4>
<div className={styles.formGrid}> <div className={styles.formGrid}>
<div className={styles.formGroup}>
<label htmlFor="dcg-rtsp-password" className={styles.label}>
RTSP password:
</label>
<input
id="dcg-rtsp-password"
type="text"
className={styles.input}
value={rtspPassword}
placeholder="password"
onChange={(e) => onRtspPasswordChange(e.target.value)}
/>
<p className={styles.helpText}>
Used as{" "}
<CodeInline>{"{FRIGATE_RTSP_PASSWORD}"}</CodeInline>{" "}
in the config file to reference camera stream passwords. This is NOT
the Frigate login password.
</p>
</div>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label htmlFor="dcg-timezone" className={styles.label}> <label htmlFor="dcg-timezone" className={styles.label}>
Timezone: Timezone:
</label> </label>
<input <select
id="dcg-timezone" id="dcg-timezone"
type="text" className={`${styles.input} ${styles.select}`}
className={styles.input} value={selectedValue}
value={timezone} onChange={(e) =>
placeholder={Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC"} onTimezoneChange(
onChange={(e) => onTimezoneChange(e.target.value)} e.target.value === AUTO_TIMEZONE_VALUE ? "" : e.target.value
/> )
}
>
<option value={AUTO_TIMEZONE_VALUE}>
Use browser timezone ({systemTimezone})
</option>
{timezones.map((tz) => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</select>
</div> </div>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label htmlFor="dcg-shm-size" className={styles.label}> <label htmlFor="dcg-shm-size" className={styles.label}>
@ -83,6 +97,25 @@ export default function OtherOptions({
</p> </p>
)} )}
</div> </div>
<div className={styles.formGroup}>
<label htmlFor="dcg-rtsp-password" className={styles.label}>
RTSP password:
</label>
<input
id="dcg-rtsp-password"
type="text"
className={styles.input}
value={rtspPassword}
placeholder="password"
onChange={(e) => onRtspPasswordChange(e.target.value)}
/>
<p className={styles.helpText}>
Optional. You can specify{" "}
<CodeInline>{"{FRIGATE_RTSP_PASSWORD}"}</CodeInline>{" "}
in the config file to reference camera stream passwords. This is NOT
the Frigate login password.
</p>
</div>
</div> </div>
</div> </div>
); );

View File

@ -24,7 +24,7 @@ export default function StoragePaths({
<div className={styles.formGrid}> <div className={styles.formGrid}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label htmlFor="dcg-config-path" className={styles.label}> <label htmlFor="dcg-config-path" className={styles.label}>
Config / DB / model cache directory: Config / DB / model cache directory (on your host):
</label> </label>
<input <input
id="dcg-config-path" id="dcg-config-path"
@ -43,7 +43,7 @@ export default function StoragePaths({
</div> </div>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label htmlFor="dcg-media-path" className={styles.label}> <label htmlFor="dcg-media-path" className={styles.label}>
Recording storage directory: Recording storage directory (on your host):
</label> </label>
<input <input
id="dcg-media-path" id="dcg-media-path"

View File

@ -172,7 +172,7 @@ hardware:
comment: "PCIe Coral — follow driver instructions at https://github.com/jnicolson/gasket-builder" comment: "PCIe Coral — follow driver instructions at https://github.com/jnicolson/gasket-builder"
- id: "gpu" - id: "gpu"
label: "GPU Acceleration (/dev/dri)" label: "Intel/AMD GPU (/dev/dri)"
description: "Pass through /dev/dri for GPU hardware acceleration (Intel/AMD)." description: "Pass through /dev/dri for GPU hardware acceleration (Intel/AMD)."
disabledWhen: disabledWhen:
- "stable-tensorrt-jp6" - "stable-tensorrt-jp6"
@ -198,7 +198,7 @@ hardware:
- id: "hailo" - id: "hailo"
label: "Hailo NPU (/dev/hailo0)" label: "Hailo NPU (/dev/hailo0)"
description: "Pass through /dev/hailo0 for Hailo-8 / Hailo-8L NPU acceleration." description: "Pass through /dev/hailo0 for Hailo-8 / Hailo-8L NPU acceleration. You also need to [install the driver](#hailo-8)."
disabledWhen: disabledWhen:
- "apple-silicon" - "apple-silicon"
- "stable-synaptics" - "stable-synaptics"
@ -208,7 +208,7 @@ hardware:
- id: "memryx" - id: "memryx"
label: "MemryX MX3 (/dev/memx0)" label: "MemryX MX3 (/dev/memx0)"
description: "Pass through /dev/memx0 for MemryX MX3 NPU acceleration." description: "Pass through /dev/memx0 for MemryX MX3 NPU acceleration. You also need to [install the driver](#memryx-mx3)."
disabledWhen: disabledWhen:
- "apple-silicon" - "apple-silicon"
- "stable-synaptics" - "stable-synaptics"
@ -242,7 +242,7 @@ hardware:
comment: "AXERA libraries" comment: "AXERA libraries"
- id: "video11" - id: "video11"
label: "Raspberry Pi video11" label: "Raspberry Pi (/dev/video11)"
description: "Pass through /dev/video11 for Raspberry Pi 4B hardware acceleration." description: "Pass through /dev/video11 for Raspberry Pi 4B hardware acceleration."
disabledWhen: disabledWhen:
- "stable-tensorrt" - "stable-tensorrt"
@ -272,7 +272,7 @@ ports:
host: 8554 host: 8554
container: 8554 container: 8554
protocol: "tcp" protocol: "tcp"
description: "RTSP feeds" description: "Access RTSP feeds from go2rtc"
defaultEnabled: true defaultEnabled: true
- id: "8555-tcp" - id: "8555-tcp"
@ -293,5 +293,5 @@ ports:
host: 1984 host: 1984
container: 1984 container: 1984
protocol: "tcp" protocol: "tcp"
description: "Go2RTC Web UIport" description: "Go2RTC Web UI"
defaultEnabled: false defaultEnabled: false

View File

@ -15,7 +15,7 @@ export interface GeneratorInput {
enabledPorts: string[]; enabledPorts: string[];
configPath: string; configPath: string;
mediaPath: string; mediaPath: string;
rtspPassword: string; rtspPassword?: string;
timezone: string; timezone: string;
shmSize: string; shmSize: string;
nvidiaGpuCount?: string; nvidiaGpuCount?: string;
@ -99,7 +99,7 @@ function buildPorts(enabledPorts: string[]): string[] {
function buildEnvironment( function buildEnvironment(
device: DeviceConfig, device: DeviceConfig,
hwEnv: Record<string, string>, hwEnv: Record<string, string>,
rtspPassword: string, rtspPassword: string | undefined,
timezone: string timezone: string
): string[] { ): string[] {
const allEnv: Record<string, string> = { const allEnv: Record<string, string> = {
@ -107,11 +107,15 @@ function buildEnvironment(
...(device.env ?? {}), ...(device.env ?? {}),
}; };
const lines: string[] = [ const lines: string[] = [" environment:"];
" environment:",
` FRIGATE_RTSP_PASSWORD: "${rtspPassword}" # RTSP password — change to your own`, if (rtspPassword) {
` TZ: "${timezone}" # Timezone`, lines.push(
]; ` FRIGATE_RTSP_PASSWORD: "${rtspPassword}" # RTSP password — change to your own`
);
}
lines.push(` TZ: "${timezone}" # Timezone`);
for (const [key, value] of Object.entries(allEnv)) { for (const [key, value] of Object.entries(allEnv)) {
lines.push(` ${key}: "${value}"`); lines.push(` ${key}: "${value}"`);

View File

@ -33,10 +33,8 @@ export function useConfigGenerator() {
const [nvidiaGpuDeviceId, setNvidiaGpuDeviceId] = useState(""); const [nvidiaGpuDeviceId, setNvidiaGpuDeviceId] = useState("");
const [configPath, setConfigPath] = useState(""); const [configPath, setConfigPath] = useState("");
const [mediaPath, setMediaPath] = useState(""); const [mediaPath, setMediaPath] = useState("");
const [rtspPassword, setRtspPassword] = useState("password"); const [rtspPassword, setRtspPassword] = useState("");
const [timezone, setTimezone] = useState( const [timezone, setTimezone] = useState("");
() => Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC"
);
const [shmSize, setShmSize] = useState("512mb"); const [shmSize, setShmSize] = useState("512mb");
const [shmSizeError, setShmSizeError] = useState(false); const [shmSizeError, setShmSizeError] = useState(false);
const [gpuDeviceIdError, setGpuDeviceIdError] = useState(false); const [gpuDeviceIdError, setGpuDeviceIdError] = useState(false);
@ -168,7 +166,7 @@ export function useConfigGenerator() {
enabledPorts: enabledPortLines, enabledPorts: enabledPortLines,
configPath: configPath || "/path/to/your/config", configPath: configPath || "/path/to/your/config",
mediaPath: mediaPath || "/path/to/your/storage", mediaPath: mediaPath || "/path/to/your/storage",
rtspPassword: rtspPassword || "password", rtspPassword,
timezone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC", timezone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC",
shmSize: shmSize || "512mb", shmSize: shmSize || "512mb",
nvidiaGpuCount, nvidiaGpuCount,

View File

@ -103,6 +103,21 @@
} }
} }
/* --- Select dropdown --- */
.select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2rem;
}
[data-theme="light"] .select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23555' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
}
.helpText { .helpText {
margin: 0.5rem 0 0 0; margin: 0.5rem 0 0 0;
font-size: 0.85rem; font-size: 0.85rem;

View File

@ -8,7 +8,6 @@ import os
import queue import queue
import subprocess as sp import subprocess as sp
import threading import threading
import time
import traceback import traceback
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from typing import Any, Optional from typing import Any, Optional

View File

@ -28,7 +28,7 @@ from frigate.ffmpeg_presets import (
EncodeTypeEnum, EncodeTypeEnum,
parse_preset_hardware_acceleration_encode, parse_preset_hardware_acceleration_encode,
) )
from frigate.models import Export, Previews, Recordings from frigate.models import Export, Previews, Recordings, ReviewSegment
from frigate.util.time import is_current_hour from frigate.util.time import is_current_hour
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -347,6 +347,122 @@ class RecordingExporter(threading.Thread):
# return in iso format # return in iso format
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
def _chapter_metadata_path(self) -> str:
return os.path.join(CACHE_DIR, f"export_chapters_{self.export_id}.txt")
def _build_chapter_metadata_file(self, recordings: list) -> Optional[str]:
"""Write an FFmpeg metadata file with chapters for review items in range.
Chapter offsets are computed in *output time*: the VOD endpoint
concatenates recording clips back-to-back, so wall-clock gaps
between recordings collapse in the produced video. We walk the
same recording rows that feed the playlist and convert each
review item's wall-clock boundaries into output-time offsets.
Returns ``None`` when there are no recordings, no review items,
or any chapter would have zero output duration.
"""
if not recordings:
return None
windows: list[tuple[float, float, float]] = []
output_offset = 0.0
for rec in recordings:
clipped_start = max(float(rec.start_time), float(self.start_time))
clipped_end = min(float(rec.end_time), float(self.end_time))
if clipped_end <= clipped_start:
continue
windows.append((clipped_start, clipped_end, output_offset))
output_offset += clipped_end - clipped_start
if not windows:
return None
try:
review_rows = list(
ReviewSegment.select(
ReviewSegment.start_time,
ReviewSegment.end_time,
ReviewSegment.severity,
ReviewSegment.data,
)
.where(
ReviewSegment.start_time.between(self.start_time, self.end_time)
| ReviewSegment.end_time.between(self.start_time, self.end_time)
| (
(self.start_time > ReviewSegment.start_time)
& (self.end_time < ReviewSegment.end_time)
)
)
.where(ReviewSegment.camera == self.camera)
.order_by(ReviewSegment.start_time.asc())
.iterator()
)
except Exception:
logger.exception(
"Failed to query review segments for export %s", self.export_id
)
return None
if not review_rows:
return None
total_output = windows[-1][2] + (windows[-1][1] - windows[-1][0])
def wall_to_output(t: float) -> float:
t = max(float(self.start_time), min(float(self.end_time), t))
for w_start, w_end, w_offset in windows:
if t < w_start:
return w_offset
if t <= w_end:
return w_offset + (t - w_start)
return total_output
chapter_blocks: list[str] = []
for review in review_rows:
start_out = wall_to_output(float(review.start_time))
end_out = wall_to_output(float(review.end_time))
# Drop chapters that fall entirely in a recording gap, or are
# too short to be navigable in a player.
if end_out - start_out < 1.0:
continue
data = review.data or {}
labels: list[str] = []
for obj in data.get("objects") or []:
label = str(obj).split("-")[0]
if label and label not in labels:
labels.append(label)
title = str(review.severity).capitalize()
if labels:
title = f"{title}: {', '.join(labels)}"
chapter_blocks.append(
"[CHAPTER]\n"
"TIMEBASE=1/1000\n"
f"START={int(start_out * 1000)}\n"
f"END={int(end_out * 1000)}\n"
f"title={title}"
)
if not chapter_blocks:
return None
meta_path = self._chapter_metadata_path()
try:
with open(meta_path, "w", encoding="utf-8") as f:
f.write(";FFMETADATA1\n")
f.write("\n".join(chapter_blocks))
f.write("\n")
except OSError:
logger.exception(
"Failed to write chapter metadata file for export %s", self.export_id
)
return None
return meta_path
def save_thumbnail(self, id: str) -> str: def save_thumbnail(self, id: str) -> str:
thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp") thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp")
@ -451,6 +567,24 @@ class RecordingExporter(threading.Thread):
if type(internal_port) is str: if type(internal_port) is str:
internal_port = int(internal_port.split(":")[-1]) internal_port = int(internal_port.split(":")[-1])
recordings = list(
Recordings.select(
Recordings.start_time,
Recordings.end_time,
)
.where(
Recordings.start_time.between(self.start_time, self.end_time)
| Recordings.end_time.between(self.start_time, self.end_time)
| (
(self.start_time > Recordings.start_time)
& (self.end_time < Recordings.end_time)
)
)
.where(Recordings.camera == self.camera)
.order_by(Recordings.start_time.asc())
.iterator()
)
playlist_lines: list[str] = [] playlist_lines: list[str] = []
if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS: if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS:
playlist_url = f"http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8" playlist_url = f"http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
@ -458,32 +592,13 @@ class RecordingExporter(threading.Thread):
f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_url}" f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_url}"
) )
else: else:
# get full set of recordings # Chunk the recording rows into pages so each playlist line
export_recordings = ( # references a bounded sub-range rather than the full export.
Recordings.select(
Recordings.start_time,
Recordings.end_time,
)
.where(
Recordings.start_time.between(self.start_time, self.end_time)
| Recordings.end_time.between(self.start_time, self.end_time)
| (
(self.start_time > Recordings.start_time)
& (self.end_time < Recordings.end_time)
)
)
.where(Recordings.camera == self.camera)
.order_by(Recordings.start_time.asc())
)
# Use pagination to process records in chunks
page_size = 1000 page_size = 1000
num_pages = (export_recordings.count() + page_size - 1) // page_size for i in range(0, len(recordings), page_size):
chunk = recordings[i : i + page_size]
for page in range(1, num_pages + 1):
playlist = export_recordings.paginate(page, page_size)
playlist_lines.append( playlist_lines.append(
f"file 'http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{float(playlist[0].start_time)}/end/{float(playlist[-1].end_time)}/index.m3u8'" f"file 'http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{float(chunk[0].start_time)}/end/{float(chunk[-1].end_time)}/index.m3u8'"
) )
ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin" ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin"
@ -504,8 +619,12 @@ class RecordingExporter(threading.Thread):
) )
).split(" ") ).split(" ")
else: else:
chapters_path = self._build_chapter_metadata_file(recordings)
chapter_args = (
f" -i {chapters_path} -map 0 -map_metadata 1" if chapters_path else ""
)
ffmpeg_cmd = ( ffmpeg_cmd = (
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart" f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart"
).split(" ") ).split(" ")
# add metadata # add metadata
@ -691,6 +810,8 @@ class RecordingExporter(threading.Thread):
ffmpeg_cmd, playlist_lines, step="encoding_retry" ffmpeg_cmd, playlist_lines, step="encoding_retry"
) )
Path(self._chapter_metadata_path()).unlink(missing_ok=True)
if returncode != 0: if returncode != 0:
logger.error( logger.error(
f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}" f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}"

View File

@ -85,7 +85,7 @@ export default function ReviewCard({
{ playback: "realtime" }, { playback: "realtime" },
) )
.then((response) => { .then((response) => {
if (response.status == 200) { if (response.status < 300) {
toast.success(t("export.toast.success"), { toast.success(t("export.toast.success"), {
position: "top-center", position: "top-center",
action: ( action: (

View File

@ -278,7 +278,7 @@ export default function EventView({
{ playback: "realtime", image_path: review.thumb_path }, { playback: "realtime", image_path: review.thumb_path },
) )
.then((response) => { .then((response) => {
if (response.status == 200) { if (response.status < 300) {
toast.success( toast.success(
t("export.toast.success", { ns: "components/dialog" }), t("export.toast.success", { ns: "components/dialog" }),
{ {

View File

@ -357,7 +357,7 @@ export default function MotionSearchView({
}, },
) )
.then((response) => { .then((response) => {
if (response.status == 200) { if (response.status < 300) {
toast.success( toast.success(
t("export.toast.success", { ns: "components/dialog" }), t("export.toast.success", { ns: "components/dialog" }),
{ {