diff --git a/docs/docs/configuration/live.md b/docs/docs/configuration/live.md index 954787a08a..84aa337071 100644 --- a/docs/docs/configuration/live.md +++ b/docs/docs/configuration/live.md @@ -38,7 +38,7 @@ Frigate [defaults to MSE](#why-does-frigate-prefer-mse-over-webrtc-for-live-view - The choices are saved **per device, per camera** in your browser's local storage. - **WebRTC is only selectable when it can actually work for that stream.** When it can't, the option is shown disabled with the reason inline, and a more detailed reason (the failing codecs, or why the connectivity check failed) is logged to your browser's console. Common reasons: - - **Not configured**: no `candidates` or `ice_servers` are set under `go2rtc.webrtc` (see [WebRTC extra configuration](#webrtc-extra-configuration)). + - **Not configured**: no `candidates` or `ice_servers` are set under `go2rtc.webrtc`, and the connectivity check failed with go2rtc's default candidates (see [WebRTC extra configuration](#webrtc-extra-configuration)). This is a setup note rather than an error, since live view works without WebRTC. - **Could not connect**: e.g. port `8555` isn't reachable, or a STUN/TURN server is misconfigured. Frigate runs a one-time WebRTC connectivity check when the Live view opens; the option may briefly show as "checking" while it runs. - **Unsupported video codec**: the stream's video codec can't be played over WebRTC in your browser, most commonly H.265/HEVC in Firefox or Edge. - **Unsupported audio codec**: WebRTC needs opus or G.711 audio, so a stream whose playback audio is only AAC (without an added opus/G.711 track) can't carry audio over WebRTC. See [Audio Support](#audio-support) for how to add one. diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 19f7d9aa24..b3c730df71 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -316,14 +316,10 @@ def get_remote_addr(request: Request): route = list(reversed(forwarded_for.split(","))) logger.debug(f"IP Route: {[r for r in route]}") - trusted_proxies = [] - for proxy in request.app.frigate_config.auth.trusted_proxies: - try: - network = ipaddress.ip_network(proxy) - except ValueError: - logger.warning(f"Unable to parse trusted network: {proxy}") - continue - trusted_proxies.append(network) + trusted_proxies = [ + ipaddress.ip_network(proxy, strict=False) + for proxy in request.app.frigate_config.auth.trusted_proxies + ] # return the first remote address that is not trusted for addr in route: diff --git a/frigate/api/export.py b/frigate/api/export.py index 3dad13fd09..f0d93bc105 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -80,6 +80,7 @@ from frigate.record.export import ( validate_ffmpeg_args, ) from frigate.util.path import sanitize_contained_path +from frigate.util.recording_coverage import recording_overlap from frigate.util.time import is_current_hour logger = logging.getLogger(__name__) @@ -177,15 +178,7 @@ def _validate_export_source( ) -> str | None: if playback_source == PlaybackSourceEnum.recordings: query = Recordings.select().where( - ( - Recordings.start_time.between(start_time, end_time) - | Recordings.end_time.between(start_time, end_time) - | ( - (start_time > Recordings.start_time) - & (end_time < Recordings.end_time) - ) - ) - & (Recordings.camera == camera_name) + recording_overlap(start_time, end_time) & (Recordings.camera == camera_name) ) # a pinned export reads only that stream, so the other stream's @@ -250,9 +243,7 @@ def _get_item_recording_export_errors( query = Recordings.select(Recordings.start_time, Recordings.end_time).where( Recordings.camera == camera_name, - Recordings.start_time.between(min_start, max_end) - | Recordings.end_time.between(min_start, max_end) - | ((min_start > Recordings.start_time) & (max_end < Recordings.end_time)), + recording_overlap(min_start, max_end), ) # a pinned batch reads only that stream, so the other stream's diff --git a/frigate/api/media.py b/frigate/api/media.py index e260aa18fd..80443371cf 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -62,6 +62,7 @@ from frigate.util.recording_coverage import ( build_spans, null_audio_glitches, plan_clip, + recording_overlap, resolve_coverage, ) from frigate.util.time import get_normalized_tz_name @@ -347,10 +348,7 @@ async def get_snapshot_from_recording( Recordings.path, Recordings.start_time, ) - .where( - (frame_time >= Recordings.start_time) - & (frame_time <= Recordings.end_time) - ) + .where(recording_overlap(frame_time, frame_time)) .where(Recordings.camera == camera_name) .order_by(Recordings.stream_type.asc(), Recordings.start_time.desc()) .limit(1) @@ -366,10 +364,7 @@ async def get_snapshot_from_recording( Recordings.path, Recordings.start_time, ) - .where( - (frame_time >= Recordings.start_time) - & (frame_time <= Recordings.end_time) - ) + .where(recording_overlap(frame_time, frame_time)) .where(Recordings.camera == camera_name) .order_by(Recordings.stream_type.asc(), Recordings.start_time.desc()) .limit(1) @@ -432,9 +427,7 @@ async def submit_recording_snapshot_to_plus( Recordings.path, Recordings.start_time, ) - .where( - (frame_time >= Recordings.start_time) & (frame_time <= Recordings.end_time) - ) + .where(recording_overlap(frame_time, frame_time)) .where(Recordings.camera == camera_name) .order_by(Recordings.stream_type.asc(), Recordings.start_time.desc()) .limit(1) @@ -548,11 +541,7 @@ async def recording_clip( Recordings.start_time, Recordings.end_time, ) - .where( - (Recordings.start_time.between(start_ts, end_ts)) - | (Recordings.end_time.between(start_ts, end_ts)) - | ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time)) - ) + .where(recording_overlap(start_ts, end_ts)) .where(Recordings.camera == camera_name) .where(Recordings.stream_type == stream_type) .order_by(Recordings.start_time.asc()) diff --git a/frigate/api/notification.py b/frigate/api/notification.py index 04749fc260..42efc94c47 100644 --- a/frigate/api/notification.py +++ b/frigate/api/notification.py @@ -9,7 +9,6 @@ from urllib.parse import urlparse from cryptography.hazmat.primitives import serialization from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse -from peewee import DoesNotExist from py_vapid import Vapid01, utils from frigate.api.auth import allow_any_authenticated @@ -173,16 +172,22 @@ def register_notifications(request: Request, body: dict = None): status_code=400, ) - try: - User.update(notification_tokens=User.notification_tokens.append(sub)).where( - User.username == username - ).execute() - return JSONResponse( - content=({"success": True, "message": "Successfully saved token."}), - status_code=200, + updated = ( + User.update(notification_tokens=User.notification_tokens.append(sub)) + .where(User.username == username) + .execute() + ) + + if not updated: + logger.warning( + "Rejected notification registration for unknown user %s", username ) - except DoesNotExist: return JSONResponse( content=({"success": False, "message": "Could not find user."}), status_code=404, ) + + return JSONResponse( + content=({"success": True, "message": "Successfully saved token."}), + status_code=200, + ) diff --git a/frigate/api/record.py b/frigate/api/record.py index 2f32e6a770..6b46243259 100644 --- a/frigate/api/record.py +++ b/frigate/api/record.py @@ -36,6 +36,7 @@ from frigate.util.recording_coverage import ( coverage_spans, known_video_codecs, realized_timelines, + recording_overlap, resolve_coverage, stream_media_summary, ) @@ -537,11 +538,7 @@ async def delete_recordings( # Build query to find overlapping recordings clauses = [ - ( - Recordings.start_time.between(start, end) - | Recordings.end_time.between(start, end) - | ((start > Recordings.start_time) & (end < Recordings.end_time)) - ), + recording_overlap(start, end), (Recordings.camera << camera_list), ] diff --git a/frigate/api/review.py b/frigate/api/review.py index c2aa29bcec..14f9bb169c 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -37,6 +37,7 @@ from frigate.const import STREAM_TYPE_MAIN from frigate.embeddings import EmbeddingsContext from frigate.models import Recordings, ReviewSegment, UserReviewStatus from frigate.review.types import SeverityEnum +from frigate.util.recording_coverage import recording_overlap from frigate.util.time import get_dst_transitions logger = logging.getLogger(__name__) @@ -585,14 +586,7 @@ def delete_reviews(body: ReviewModifyMultipleBody): camera_name = review["camera"] recordings = ( Recordings.select(Recordings.id, Recordings.path) - .where( - Recordings.start_time.between(start_time, end_time) - | Recordings.end_time.between(start_time, end_time) - | ( - (start_time > Recordings.start_time) - & (end_time < Recordings.end_time) - ) - ) + .where(recording_overlap(start_time, end_time)) .where(Recordings.camera == camera_name) .dicts() .iterator() diff --git a/frigate/app.py b/frigate/app.py index 9fe15faa42..2d1914f393 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -581,7 +581,12 @@ class FrigateApp: def init_auth(self) -> None: if self.config.auth.enabled: - if User.select().count() == 0: + if ( + User.select().count() == 0 + or User.select() + .where((User.username == "admin") & (User.password_hash == "")) + .exists() + ): password = secrets.token_hex(16) password_hash = hash_password( password, iterations=self.config.auth.hash_iterations @@ -593,6 +598,9 @@ class FrigateApp: User.password_hash: password_hash, User.notification_tokens: [], } + ).on_conflict( + conflict_target=[User.username], + update={User.password_hash: password_hash}, ).execute() self.config.auth.admin_first_time_login = True @@ -623,6 +631,19 @@ class FrigateApp: logger.info(f"*** Password: {password} ***") logger.info("********************************************************") logger.info("********************************************************") + else: + # Notification registrations store tokens on the admin row. Its + # empty password hash marks it for a generated password if auth + # is enabled later. + if User.select().count() == 0: + User.insert( + { + User.username: "admin", + User.role: "admin", + User.password_hash: "", + User.notification_tokens: [], + } + ).execute() def start(self) -> None: logger.info(f"Starting Frigate ({VERSION})") diff --git a/frigate/camera/state.py b/frigate/camera/state.py index 764ef10f61..34461e9458 100644 --- a/frigate/camera/state.py +++ b/frigate/camera/state.py @@ -392,7 +392,6 @@ class CameraState: "attributes": new_obj.obj_data["attributes"], "current_estimated_speed": 0, "velocity_angle": 0, - "path_data": [], "recognized_license_plate": None, "recognized_license_plate_score": None, } diff --git a/frigate/config/auth.py b/frigate/config/auth.py index 845ec45707..f821f0f2bf 100644 --- a/frigate/config/auth.py +++ b/frigate/config/auth.py @@ -1,3 +1,5 @@ +import ipaddress + from pydantic import Field, field_validator, model_validator from .base import FrigateBaseModel @@ -68,6 +70,14 @@ class AuthConfig(FrigateBaseModel): ), ) + @field_validator("trusted_proxies") + @classmethod + def validate_trusted_proxies(cls, v: list[str]) -> list[str]: + for proxy in v: + ipaddress.ip_network(proxy, strict=False) + + return v + @field_validator("roles") @classmethod def validate_roles(cls, v: dict[str, list[str]]) -> dict[str, list[str]]: diff --git a/frigate/data_processing/post/review_descriptions.py b/frigate/data_processing/post/review_descriptions.py index a4e80cb6b3..431472e6f4 100644 --- a/frigate/data_processing/post/review_descriptions.py +++ b/frigate/data_processing/post/review_descriptions.py @@ -37,6 +37,7 @@ from frigate.genai.manager import GenAIClientManager from frigate.models import Recordings, ReviewSegment from frigate.util.builtin import EventsPerSecond, InferenceSpeed from frigate.util.image import get_image_from_recording +from frigate.util.recording_coverage import recording_overlap from ..post.api import PostProcessorApi from ..types import DataProcessorMetrics @@ -592,7 +593,7 @@ class ReviewDescriptionProcessor(PostProcessorApi): Recordings.path, Recordings.start_time, ) - .where((ts >= Recordings.start_time) & (ts <= Recordings.end_time)) + .where(recording_overlap(ts, ts)) .where(Recordings.camera == camera) .where(Recordings.stream_type == STREAM_TYPE_MAIN) .order_by(Recordings.start_time.desc()) diff --git a/frigate/jobs/debug_replay.py b/frigate/jobs/debug_replay.py index a530392bd7..8cfe6402b3 100644 --- a/frigate/jobs/debug_replay.py +++ b/frigate/jobs/debug_replay.py @@ -27,6 +27,7 @@ from frigate.jobs.manager import job_is_running, set_current_job from frigate.models import Export, Recordings from frigate.types import JobStatusTypesEnum from frigate.util.ffmpeg import run_ffmpeg_with_progress +from frigate.util.recording_coverage import recording_overlap if TYPE_CHECKING: from frigate.debug_replay import DebugReplayManager @@ -104,11 +105,7 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode Recordings.start_time, Recordings.end_time, ) - .where( - Recordings.start_time.between(start_ts, end_ts) - | Recordings.end_time.between(start_ts, end_ts) - | ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time)) - ) + .where(recording_overlap(start_ts, end_ts)) .where(Recordings.camera == source_camera) .order_by(Recordings.start_time.asc()) ) diff --git a/frigate/jobs/motion_search.py b/frigate/jobs/motion_search.py index 5944a9e60b..da33f276b8 100644 --- a/frigate/jobs/motion_search.py +++ b/frigate/jobs/motion_search.py @@ -35,6 +35,7 @@ from frigate.jobs.motion_search_decode import ( ) from frigate.models import Recordings from frigate.types import JobStatusTypesEnum +from frigate.util.recording_coverage import recording_overlap logger = logging.getLogger(__name__) @@ -469,20 +470,7 @@ class MotionSearchRunner(threading.Thread): recordings = list( Recordings.select() .where( - ( - Recordings.start_time.between( - self.job.start_time_range, self.job.end_time_range - ) - ) - | ( - Recordings.end_time.between( - self.job.start_time_range, self.job.end_time_range - ) - ) - | ( - (self.job.start_time_range > Recordings.start_time) - & (self.job.end_time_range < Recordings.end_time) - ) + recording_overlap(self.job.start_time_range, self.job.end_time_range) ) .where(Recordings.camera == camera_name) .where(Recordings.stream_type == STREAM_TYPE_MAIN) diff --git a/frigate/record/export.py b/frigate/record/export.py index 2ec58d664a..5efa4914fa 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -45,6 +45,7 @@ from frigate.util.recording_coverage import ( known_video_codecs, null_audio_glitches, realized_timeline, + recording_overlap, resolve_coverage, stream_media_summary, ) @@ -773,14 +774,7 @@ class RecordingExporter(threading.Thread): 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(recording_overlap(self.start_time, self.end_time)) .where( (Recordings.camera == self.camera) & (Recordings.stream_type == stream_type) diff --git a/frigate/test/http_api/test_http_notifications.py b/frigate/test/http_api/test_http_notifications.py new file mode 100644 index 0000000000..42ca018f88 --- /dev/null +++ b/frigate/test/http_api/test_http_notifications.py @@ -0,0 +1,59 @@ +"""Tests for notification registration and the admin row it relies on.""" + +from types import SimpleNamespace + +from frigate.app import FrigateApp +from frigate.config import FrigateConfig +from frigate.models import User +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + +SUBSCRIPTION = { + "endpoint": "https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu", + "keys": {"p256dh": "cHVibGljLWtleQ", "auth": "YXV0aC1zZWNyZXQ"}, +} + + +class TestNotificationRegistration(BaseTestHttp): + def setUp(self): + super().setUp([User]) + + def _init_auth(self, enabled: bool) -> FrigateConfig: + config = FrigateConfig(**{**self.minimal_config, "auth": {"enabled": enabled}}) + FrigateApp.init_auth(SimpleNamespace(config=config)) + return config + + def _register(self): + self.minimal_config["auth"] = {"enabled": False} + with AuthTestClient(self.create_app()) as client: + return client.post("/notifications/register", json={"sub": SUBSCRIPTION}) + + def test_auth_disabled_fresh_install_persists_token(self): + self._init_auth(enabled=False) + + assert self._register().status_code == 200 + admin = User.get(User.username == "admin") + assert admin.password_hash == "" + assert admin.notification_tokens == [SUBSCRIPTION] + + def test_auth_disabled_does_not_recreate_deleted_admin(self): + User.insert( + username="viewer1", + role="viewer", + password_hash="x$1$y$z", + notification_tokens=[], + ).execute() + self._init_auth(enabled=False) + + assert User.get_or_none(User.username == "admin") is None + assert self._register().status_code == 404 + + def test_enabling_auth_sets_password_and_keeps_tokens(self): + self._init_auth(enabled=False) + assert self._register().status_code == 200 + + config = self._init_auth(enabled=True) + + admin = User.get(User.username == "admin") + assert admin.password_hash.count("$") == 3 + assert admin.notification_tokens == [SUBSCRIPTION] + assert config.auth.admin_first_time_login is True diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index 8818f22457..bc9d08afdd 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -75,6 +75,16 @@ class TestConfig(unittest.TestCase): DetectorTypeEnum.cpu ) + def test_trusted_proxies_reject_invalid_networks(self): + config = { + "auth": {"trusted_proxies": ["172.18.0.0/16", "192.168.1.1/24", "fd00::/8"]} + } + FrigateConfig(**deep_merge(config, self.minimal)) + + config = {"auth": {"trusted_proxies": ["172.18.0.0/33"]}} + with self.assertRaises(ValidationError): + FrigateConfig(**deep_merge(config, self.minimal)) + @patch("frigate.detectors.detector_config.load_labels") def test_model_custom_path(self, mock_labels): mock_labels.return_value = {} diff --git a/frigate/test/test_recording_coverage.py b/frigate/test/test_recording_coverage.py index b264a31f8a..c559ec4997 100644 --- a/frigate/test/test_recording_coverage.py +++ b/frigate/test/test_recording_coverage.py @@ -16,6 +16,7 @@ from frigate.util.recording_coverage import ( null_audio_glitches, plan_clip, realized_timeline, + recording_overlap, resolve_coverage, stream_media_summary, ) @@ -276,6 +277,33 @@ class TestRecordingCoverage(CoverageDbTestCase): assert plan.duration_ms == 0 +class TestRecordingOverlap(CoverageDbTestCase): + def test_matches_unbounded_overlap(self): + spans = [(1000, 1010), (1010, 1020), (1100, 1100 + MAX_SEGMENT_DURATION - 0.5)] + for i, (start, end) in enumerate(spans): + self._insert(f"r{i}", start, end, "main") + + probes = [995, 1000, 1005, 1010, 1015, 1020, 1050, 1100, 1650, 1699.5, 1700] + for after in probes: + for before in probes: + if before < after: + continue + + with self.subTest(after=after, before=before): + unbounded = Recordings.select(Recordings.id).where( + Recordings.start_time.between(after, before) + | Recordings.end_time.between(after, before) + | ( + (after > Recordings.start_time) + & (before < Recordings.end_time) + ) + ) + bounded = Recordings.select(Recordings.id).where( + recording_overlap(after, before) + ) + self.assertEqual({r.id for r in bounded}, {r.id for r in unbounded}) + + class TestVodManifestPolicy(CoverageDbTestCase): """Discontinuity policy of the vod mapping builder.""" diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index 24ae278ac8..9d0dd459fd 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -169,7 +169,6 @@ class TrackedObject: "attributes": obj_data["attributes"], "current_estimated_speed": self.current_estimated_speed, "velocity_angle": self.velocity_angle, - "path_data": self.path_data.copy(), "recognized_license_plate": obj_data.get( "recognized_license_plate" ), diff --git a/frigate/util/audio.py b/frigate/util/audio.py index e06a40a2df..1666cee139 100644 --- a/frigate/util/audio.py +++ b/frigate/util/audio.py @@ -19,6 +19,7 @@ from frigate.const import ( STREAM_TYPE_SUB, ) from frigate.models import Recordings +from frigate.util.recording_coverage import recording_overlap logger = logging.getLogger(__name__) @@ -67,11 +68,7 @@ def _get_recordings_for_range( Recordings.start_time, Recordings.end_time, ) - .where( - (Recordings.start_time.between(start_ts, end_ts)) - | (Recordings.end_time.between(start_ts, end_ts)) - | ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time)) - ) + .where(recording_overlap(start_ts, end_ts)) .where(Recordings.camera == camera_name) .where(Recordings.stream_type == stream_type) .order_by(Recordings.start_time.asc()) diff --git a/frigate/util/classification.py b/frigate/util/classification.py index 6c4daabfb8..b750451209 100644 --- a/frigate/util/classification.py +++ b/frigate/util/classification.py @@ -32,6 +32,7 @@ from frigate.util.image import ( relative_box_to_absolute, ) from frigate.util.process import FrigateProcess +from frigate.util.recording_coverage import recording_overlap BATCH_SIZE = 16 EPOCHS = 50 @@ -553,8 +554,7 @@ def _extract_keyframes( recording = ( Recordings.select() .where( - (timestamp >= Recordings.start_time) - & (timestamp <= Recordings.end_time) + recording_overlap(timestamp, timestamp) & (Recordings.camera == camera) & (Recordings.stream_type == STREAM_TYPE_MAIN) ) diff --git a/frigate/util/recording_coverage.py b/frigate/util/recording_coverage.py index 55ca2449a9..5c059811e1 100644 --- a/frigate/util/recording_coverage.py +++ b/frigate/util/recording_coverage.py @@ -23,6 +23,19 @@ class CoverageInterval: sub: Any | None +def recording_overlap(after: float, before: float) -> Any: + """Match recordings overlapping [after, before] as a bounded index range. + + Segments never reach MAX_SEGMENT_DURATION, so the start_time lower bound + drops no rows and keeps SQLite from walking the camera's whole history. + """ + return ( + (Recordings.start_time <= before) + & (Recordings.end_time >= after) + & (Recordings.start_time >= after - MAX_SEGMENT_DURATION) + ) + + def _rows_query(camera: str, after: float, before: float, stream_type: str) -> Any: return ( Recordings.select( diff --git a/web/e2e/specs/live-webrtc-availability.spec.ts b/web/e2e/specs/live-webrtc-availability.spec.ts index b36944138d..49fc5668bd 100644 --- a/web/e2e/specs/live-webrtc-availability.spec.ts +++ b/web/e2e/specs/live-webrtc-availability.spec.ts @@ -110,6 +110,15 @@ test.describe("WebRTC availability gating @critical", () => { // distinguishes enabled from disabled options. const mseOption = frigateApp.page.getByRole("option", { name: /MSE/ }); await expect(mseOption).not.toHaveAttribute("aria-disabled", "true"); + + // a missing setting, shown as a neutral note rather than an error + await expect(webrtcOption).toContainText( + "under go2rtc → webrtc → candidates", + ); + await expect(webrtcOption).toContainText( + "Optional: live view works without it.", + ); + await expect(webrtcOption.locator(".text-danger")).toHaveCount(0); }); test("the unavailable reason is logged to the console once", async ({ @@ -214,6 +223,62 @@ test.describe("WebRTC availability gating @critical", () => { await expect(webrtcOption).toContainText(/Checking WebRTC availability/i); }); + test("desktop: unset candidates run the probe before reporting not configured", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop dropdown only"); + + // Count probe connections. The probe fails fast against the preview + // server, which with go2rtc's default candidates means setup is missing. + let probes = 0; + await frigateApp.page.routeWebSocket("**/live/webrtc/api/ws**", (ws) => { + probes++; + ws.close(); + }); + + await frigateApp.installDefaults({ + config: { + go2rtc: { + streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] }, + }, + }, + }); + + await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) => + route.fulfill({ + json: { + producers: [{ medias: ["video, recvonly, H264"] }], + consumers: [], + }, + }), + ); + + await frigateApp.goto("/#front_door"); + + const live = new LivePage(frigateApp.page, true); + await expect(live.backButton).toBeVisible({ timeout: 10_000 }); + + await frigateApp.page.locator("button:has(svg)").last().click(); + + const menu = frigateApp.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + await expect(menu).toBeVisible({ timeout: 3_000 }); + await menu + .locator('div:has(> label[for="streaming-mode"]) [role="combobox"]') + .first() + .click(); + + const webrtcOption = frigateApp.page.getByRole("option", { + name: /WebRTC/, + }); + await expect(webrtcOption).toBeVisible({ timeout: 3_000 }); + await expect(webrtcOption).toContainText( + "under go2rtc → webrtc → candidates", + ); + expect(probes).toBeGreaterThan(0); + }); + test("desktop: JSMpeg is no longer offered in the technology selector", async ({ frigateApp, }) => { diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json index c5b370cea0..c4f3f91b0b 100644 --- a/web/public/locales/en/views/live.json +++ b/web/public/locales/en/views/live.json @@ -187,7 +187,8 @@ }, "unavailable": { "browser": "Your browser does not support WebRTC.", - "not-configured": "WebRTC is not configured. Set go2rtc webrtc candidates or ice_servers.", + "not-configured": "To enable, add this server's IP:8555 under go2rtc → webrtc → candidates.", + "notConfiguredOptional": "Optional: live view works without it. WebRTC is mainly needed for two-way talk.", "unreachable": "WebRTC could not connect. Check that port 8555 is reachable and any STUN/TURN server is correct.", "video-codec": "This stream's video codec is not supported by WebRTC in this browser.", "audio-codec": "This stream's audio codec is not supported by WebRTC. Transcode to opus or G.711 with go2rtc.", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index ebff844860..6025b89136 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1041,7 +1041,8 @@ "title": "Users", "management": { "title": "User Management", - "desc": "Manage this Frigate instance's user accounts." + "desc": "Manage this Frigate instance's user accounts.", + "authDisabled": "Built-in authentication is disabled. These accounts are only used to log in once it's enabled." }, "addUser": "Add User", "updatePassword": "Reset Password", @@ -1310,6 +1311,7 @@ "loadingAvailableModels": "Loading available models…", "selectModel": "Select a model", "noModelsAvailable": "No models available", + "showIncompatible": "Show incompatible models", "filter": { "ariaLabel": "Filter models by type", "baseModels": "Base Models", diff --git a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx index c2f75dd383..98c2e9caaa 100644 --- a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx +++ b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx @@ -403,6 +403,11 @@ export default function NotificationsSettingsExtras({ .post("notifications/register", { sub: pushSubscription, }) + .then(() => { + toast.success(t("notification.toast.success.registered"), { + position: "top-center", + }); + }) .catch(() => { toast.error(t("notification.toast.error.registerFailed"), { position: "top-center", @@ -411,9 +416,6 @@ export default function NotificationsSettingsExtras({ workerRegistration.unregister(); setRegistration(null); }); - toast.success(t("notification.toast.success.registered"), { - position: "top-center", - }); }); }, [addMessage, publicKey, t], diff --git a/web/src/components/config-form/theme/fields/ModelSourcePicker.tsx b/web/src/components/config-form/theme/fields/ModelSourcePicker.tsx index 70d1bbbbfc..3fabf83138 100644 --- a/web/src/components/config-form/theme/fields/ModelSourcePicker.tsx +++ b/web/src/components/config-form/theme/fields/ModelSourcePicker.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useMemo, useState } from "react"; +import { ReactNode, useId, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import useSWR from "swr"; import axios from "axios"; @@ -10,6 +10,7 @@ import { SelectItem, SelectTrigger, } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { FrigateConfig } from "@/types/frigateConfig"; import { describeSupportedDetectors } from "@/utils/plusModels"; @@ -79,10 +80,8 @@ export function ModelSourcePicker({ }, }); - const entries = useMemo( - () => Object.entries(availableModels ?? {}), - [availableModels], - ); + const [showIncompatible, setShowIncompatible] = useState(false); + const showIncompatibleId = useId(); // the tab cannot be derived from the path alone: switching to Frigate+ // leaves the path untouched until a model is picked @@ -102,6 +101,16 @@ export function ModelSourcePicker({ const isCompatible = (model: FrigatePlusModel) => !detector || model.supportedDetectors.includes(detector); + const allEntries = Object.entries(availableModels ?? {}); + const hasIncompatible = allEntries.some(([, model]) => !isCompatible(model)); + + // the saved model stays listed even when incompatible + const entries = showIncompatible + ? allEntries + : allEntries.filter( + ([id, model]) => isCompatible(model) || id === selectedId, + ); + if (!plusEnabled) { return