Compare commits

...

7 Commits

Author SHA1 Message Date
Douwe
bb184f8ea3
Merge 517c1b76f4 into 16d94c3cfa 2026-01-20 14:31:14 +01:00
John Shaw
16d94c3cfa
Remove parents in remove_empty_directories (#21726)
The original implementation did a full directory tree walk to find and remove
empty directories, so this implementation should remove the parents as well,
like the original did.
2026-01-19 21:24:27 -07:00
dhoeben
517c1b76f4 fix linting 2025-12-28 11:20:45 +01:00
dhoeben
067c26f271 Fix linting 2025-12-23 18:17:56 +01:00
dhoeben
8c508b53d0 Fixed security issues 2025-12-23 15:06:02 +01:00
dhoeben
3ca2231e10 Run Prettier 2025-12-23 15:06:02 +01:00
dhoeben
90c61eeff6 Add custom theme support 2025-12-23 15:05:53 +01:00
6 changed files with 127 additions and 20 deletions

View File

@ -5,6 +5,7 @@ import copy
import json import json
import logging import logging
import os import os
import re
import traceback import traceback
import urllib import urllib
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -32,6 +33,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.const import THEMES_DIR
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
from frigate.jobs.media_sync import ( from frigate.jobs.media_sync import (
get_current_media_sync_job, get_current_media_sync_job,
@ -190,6 +192,28 @@ def config(request: Request):
return JSONResponse(content=config) return JSONResponse(content=config)
@router.get("/config/themes")
def config_themes():
themes_dir = THEMES_DIR
if not os.path.isdir(themes_dir):
return JSONResponse(content=[])
themes: list[str] = []
for name in sorted(os.listdir(themes_dir)):
if not name.lower().endswith(".css"):
continue
if not re.fullmatch(r"[a-zA-Z0-9._-]+\.css", name):
continue
full_path = os.path.join(themes_dir, name)
if os.path.isfile(full_path):
themes.append(name)
return JSONResponse(content=themes)
@router.get("/config/raw_paths", dependencies=[Depends(require_role(["admin"]))]) @router.get("/config/raw_paths", dependencies=[Depends(require_role(["admin"]))])
def config_raw_paths(request: Request): def config_raw_paths(request: Request):
"""Admin-only endpoint that returns camera paths and go2rtc streams without credential masking.""" """Admin-only endpoint that returns camera paths and go2rtc streams without credential masking."""

View File

@ -5,6 +5,7 @@ INSTALL_DIR = "/opt/frigate"
CONFIG_DIR = "/config" CONFIG_DIR = "/config"
DEFAULT_DB_PATH = f"{CONFIG_DIR}/frigate.db" DEFAULT_DB_PATH = f"{CONFIG_DIR}/frigate.db"
MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache" MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache"
THEMES_DIR = f"{CONFIG_DIR}/themes"
BASE_DIR = "/media/frigate" BASE_DIR = "/media/frigate"
CLIPS_DIR = f"{BASE_DIR}/clips" CLIPS_DIR = f"{BASE_DIR}/clips"
EXPORT_DIR = f"{BASE_DIR}/exports" EXPORT_DIR = f"{BASE_DIR}/exports"

View File

@ -11,7 +11,7 @@ from pathlib import Path
from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum
from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR
from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus
from frigate.util.builtin import clear_and_unlink from frigate.util.builtin import clear_and_unlink
from frigate.util.media import remove_empty_directories from frigate.util.media import remove_empty_directories
@ -376,5 +376,5 @@ class RecordingCleanup(threading.Thread):
if counter == 0: if counter == 0:
self.clean_tmp_clips() self.clean_tmp_clips()
maybe_empty_dirs = self.expire_recordings() maybe_empty_dirs = self.expire_recordings()
remove_empty_directories(maybe_empty_dirs) remove_empty_directories(Path(RECORD_DIR), maybe_empty_dirs)
self.truncate_wal() self.truncate_wal()

View File

@ -50,22 +50,36 @@ class SyncResult:
} }
def remove_empty_directories(paths: Iterable[Path]) -> None: def remove_empty_directories(root: Path, paths: Iterable[Path]) -> None:
""" """
Remove directories if they exist and are empty. Remove directories if they exist and are empty.
Silently ignores non-existent and non-empty directories. Silently ignores non-existent and non-empty directories.
Attempts to remove parent directories as well, stopping at the given root.
""" """
count = 0 count = 0
for path in paths: while True:
try: parents = set()
path.rmdir() for path in paths:
except FileNotFoundError: if path == root:
continue
except OSError as e:
if e.errno == errno.ENOTEMPTY:
continue continue
raise
count += 1 try:
path.rmdir()
count += 1
except FileNotFoundError:
pass
except OSError as e:
if e.errno == errno.ENOTEMPTY:
continue
raise
parents.add(path.parent)
if not parents:
break
paths = parents
logger.debug("Removed {count} empty directories") logger.debug("Removed {count} empty directories")

View File

@ -487,11 +487,11 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
{scheme === colorScheme ? ( {scheme === colorScheme ? (
<> <>
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" /> <IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
{t(friendlyColorSchemeName(scheme))} {friendlyColorSchemeName(scheme, t)}
</> </>
) : ( ) : (
<span className="ml-6 mr-2"> <span className="ml-6 mr-2">
{t(friendlyColorSchemeName(scheme))} {friendlyColorSchemeName(scheme, t)}
</span> </span>
)} )}
</MenuItem> </MenuItem>

View File

@ -1,4 +1,5 @@
import { createContext, useContext, useEffect, useMemo, useState } from "react"; import { createContext, useContext, useEffect, useMemo, useState } from "react";
import useSWR from "swr";
type Theme = "dark" | "light" | "system"; type Theme = "dark" | "light" | "system";
type ColorScheme = type ColorScheme =
@ -21,9 +22,22 @@ export const colorSchemes: ColorScheme[] = [
// Helper function to generate friendly color scheme names // Helper function to generate friendly color scheme names
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export const friendlyColorSchemeName = (className: string): string => { export const friendlyColorSchemeName = (
const words = className.split("-").slice(1); // Exclude the first word (e.g., 'theme') className: string,
return "menu.theme." + words.join(""); t?: (key: string, options?: any) => string,
): string => {
const words = className.split("-").slice(1);
const key = "menu.theme." + words.join("");
if (!t) {
return key;
}
const fallback = words
.join(" ")
.replace(/\b\w/g, (char) => char.toUpperCase());
return t(key, { defaultValue: fallback });
}; };
type ThemeProviderProps = { type ThemeProviderProps = {
@ -51,6 +65,9 @@ const initialState: ThemeProviderState = {
const ThemeProviderContext = createContext<ThemeProviderState>(initialState); const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
const fetcher = (url: string) =>
fetch(url).then((res) => (res.ok ? res.json() : []));
export function ThemeProvider({ export function ThemeProvider({
children, children,
defaultTheme = "system", defaultTheme = "system",
@ -92,13 +109,64 @@ export function ThemeProvider({
: "light"; : "light";
}, [theme]); }, [theme]);
const { data: customFiles } = useSWR<string[]>(
"/api/config/themes",
fetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
},
);
const allColorSchemes = useMemo(() => {
const customSchemes =
customFiles
?.filter((f) => /^[a-zA-Z0-9._-]+\.css$/.test(f))
.map((f) => {
const base = f.replace(/\.css$/, "");
return (
base.startsWith("theme-") ? base : `theme-${base}`
) as ColorScheme;
}) ?? [];
return [...colorSchemes, ...customSchemes];
}, [customFiles]);
const [themesReady, setThemesReady] = useState(false);
useEffect(() => {
if (!customFiles) {
setThemesReady(true);
return;
}
const links = customFiles
.filter((f) => /^[a-zA-Z0-9._-]+\.css$/.test(f))
.map((file) => {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = `/config/themes/${file}`;
document.head.appendChild(link);
return new Promise<void>((resolve) => {
link.onload = () => resolve();
link.onerror = () => resolve();
});
});
Promise.all(links).then(() => setThemesReady(true));
}, [customFiles]);
useEffect(() => { useEffect(() => {
//localStorage.removeItem(storageKey); //localStorage.removeItem(storageKey);
//console.log(localStorage.getItem(storageKey)); //console.log(localStorage.getItem(storageKey));
if (!themesReady) {
return;
}
const root = window.document.documentElement; const root = window.document.documentElement;
root.classList.remove("light", "dark", "system", ...colorSchemes); root.classList.remove("light", "dark", "system", ...allColorSchemes);
root.classList.add(theme, colorScheme); root.classList.add(theme, colorScheme);
if (systemTheme) { if (systemTheme) {
@ -107,7 +175,7 @@ export function ThemeProvider({
} }
root.classList.add(theme); root.classList.add(theme);
}, [theme, colorScheme, systemTheme]); }, [theme, colorScheme, systemTheme, themesReady, allColorSchemes]);
const value = { const value = {
theme, theme,