diff --git a/README.md b/README.md index 3fb8b201..04ebe623 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Formerly known as: `Frigate Card` A comprehensive camera card for Home Assistant. - Live viewing of multiple cameras. -- Clips and snapshot browsing via mini-gallery. +- Clips, snapshot and review browsing via mini-gallery. - Automatic updating to continually show latest clip / snapshot. - Support for filtering events by zone and label. - Arbitrary entity access via menu (e.g. motion sensor access). diff --git a/docs/configuration/actions/custom/README.md b/docs/configuration/actions/custom/README.md index d7fb76e0..57e13aa5 100644 --- a/docs/configuration/actions/custom/README.md +++ b/docs/configuration/actions/custom/README.md @@ -175,6 +175,15 @@ action: custom:advanced-camera-card-action advanced_camera_card_action: image ``` +## `info` + +Show media metadata (e.g. event time, camera, descriptions). + +```yaml +action: custom:advanced-camera-card-action +advanced_camera_card_action: info +``` + ## `live` Change to the `live` view. @@ -385,14 +394,14 @@ advanced_camera_card_action: ptz_digital # [...] ``` -| Parameter | Default | Description | -| ----------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `action` | | Must be `custom:advanced-camera-card-action`. | -| `advanced_camera_card_action` | | Must be `ptz_digital`. | -| `target_id` | The currently selected camera or media | The target (camera or media) to execute a digital PTZ action on. Can be a camera ID, or another media ID (e.g. for Frigate, can specify a media/event ID). | -| `ptz_action` | Optional action that is one of `left`, `right`, `up`, `down`, `zoom_in` or `zoom_out`. | -| `ptz_phase` | Optional parameter that is one of `start` or `stop` to start or stop the movement separately. | -| `absolute` | Optional parameter to specify exact absolute pan and zoom settings. See below. | +| Parameter | Default | Description | +| ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `action` | | Must be `custom:advanced-camera-card-action`. | +| `advanced_camera_card_action` | | Must be `ptz_digital`. | +| `target_id` | The currently selected camera or media | The target (camera or media) to execute a digital PTZ action on. Can be a camera ID, or another media ID (e.g. for Frigate, can specify a media/event ID). | +| `ptz_action` | | Optional action that is one of `left`, `right`, `up`, `down`, `zoom_in` or `zoom_out`. | +| `ptz_phase` | | Optional parameter that is one of `start` or `stop` to start or stop the movement separately. | +| `absolute` | | Optional parameter to specify exact absolute pan and zoom settings. See below. | > [!NOTE] > If no `ptz_action` is specified and no `absolute` value is specified, the camera returns to its "home" position. See [Camera layout configuration](../../cameras/README.md?id=layout-configuration) to configure the default "home" position for digital PTZ. @@ -472,6 +481,22 @@ action: custom:advanced-camera-card-action advanced_camera_card_action: screenshot ``` +## `set_review` + +Mark a review as reviewed or unreviewed. + +```yaml +action: custom:advanced-camera-card-action +advanced_camera_card_action: set_review +# [...] +``` + +| Parameter | Default | Description | +| ----------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------- | +| `action` | | Must be `custom:advanced-camera-card-action`. | +| `advanced_camera_card_action` | | Must be `set_review`. | +| `reviewed` | | If `true` marks as reviewed, if `false` marks as unreviewed. If not specified (the default), toggles the reviewed status. | + ## `sleep` Take no action for a given duration. Useful to pause between multiple other actions. @@ -627,6 +652,12 @@ elements: tap_action: action: custom:advanced-camera-card-action advanced_camera_card_action: live + - type: custom:advanced-camera-card-menu-icon + icon: mdi:alpha-i-circle-outline + title: Info + tap_action: + action: custom:advanced-camera-card-action + advanced_camera_card_action: info - type: custom:advanced-camera-card-menu-icon icon: mdi:alpha-n-circle title: Turn off substream @@ -794,18 +825,25 @@ elements: advanced_camera_card_action: snapshots - type: custom:advanced-camera-card-menu-icon icon: mdi:alpha-h-circle-outline + title: Set reviewed + tap_action: + action: custom:advanced-camera-card-action + advanced_camera_card_action: set_review + reviewed: true + - type: custom:advanced-camera-card-menu-icon + icon: mdi:alpha-i-circle-outline title: Show timeline tap_action: action: custom:advanced-camera-card-action advanced_camera_card_action: timeline - type: custom:advanced-camera-card-menu-icon - icon: mdi:alpha-i-circle-outline + icon: mdi:alpha-j-circle-outline title: Unmute tap_action: action: custom:advanced-camera-card-action advanced_camera_card_action: unmute - type: custom:advanced-camera-card-menu-icon - icon: mdi:alpha-j-circle-outline + icon: mdi:alpha-k-circle-outline title: Add status bar contents tap_action: - action: custom:advanced-camera-card-action @@ -834,7 +872,7 @@ elements: priority: 50 sufficient: false - type: custom:advanced-camera-card-menu-icon - icon: mdi:alpha-k-circle-outline + icon: mdi:alpha-l-circle-outline title: Remove status bar contents tap_action: - action: custom:advanced-camera-card-action @@ -863,21 +901,21 @@ elements: priority: 50 sufficient: false - type: custom:advanced-camera-card-menu-icon - icon: mdi:alpha-l-circle-outline + icon: mdi:alpha-m-circle-outline title: Reset status bar contents tap_action: - action: custom:advanced-camera-card-action advanced_camera_card_action: status_bar status_bar_action: reset - type: custom:advanced-camera-card-menu-icon - icon: mdi:alpha-m-circle-outline + icon: mdi:alpha-n-circle-outline title: View Folder Media tap_action: - action: custom:advanced-camera-card-action advanced_camera_card_action: folder folder: my-folder - type: custom:advanced-camera-card-menu-icon - icon: mdi:alpha-n-circle-outline + icon: mdi:alpha-o-circle-outline title: View Folders Gallery tap_action: - action: custom:advanced-camera-card-action diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index 8943a3f0..cb66c450 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -29,6 +29,7 @@ cameras_global: | `icon` | Autodetected from `camera_entity` if that is specified. | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. | | `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. This `id` may be used in [conditions](../conditions.md), dependencies or custom [actions](../actions/README.md) to refer to a given camera unambiguously. | | `live_provider` | `auto` | The choice of live stream provider. See [Live Provider](live-provider.md). | +| `media` | | Controls the default media configuration (e.g. thumbnails) for this camera. See below. | | `proxy` | | Controls whether/how content is proxied via [hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) (must be installed separately). See below. | | `title` | Autodetected from `camera_entity` if that is specified. | A friendly name for this camera to use in the card. | | `triggers` | | Define what should cause this camera to update/trigger. See below. | @@ -165,6 +166,36 @@ cameras: | -------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `width_factor` | `1` | Width multiplier for this camera in grid mode (minimum: `0.1`). When selected, width becomes `width_factor * grid_selected_width_factor`, capped at 100%. | +## `media` + +The `media` block configures the default media options for this camera which +defines which media is shown as thumbnails and on the timeline. + +```yaml +cameras: + - camera_entity: camera.office + media: + # [...] +``` + +| Option | Default | Description | +| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `type` | `auto` | The default media type to show for this camera. One of `auto`, `events`, `recordings`, `reviews` or `folder`. See below for description of each. | +| `events_type` | `all` | If `type` is `events`, what subtype of events to show. One of `clips`, `snapshots` or `all` (default). | +| `folders` | | An optional list of folder IDs to use when `type` is `folder`. If not specified, and `type` is `folder`, will default to showing the default (first) configured folder. See [Folder Configuration](../folders.md). | + +### Media Types + +Not all camera engines support all media types. See [Camera Engines](engine.md) for details. + +| Type | Description | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `events` | Typically represents an interesting event recorded from the camera, in either a video clip or image snapshot (see `events_type` parameter). | +| `recordings` | Typically represents continuous video recordings from the camera. | +| `reviews` | Typically represents an alert / detection of some kind that the user can review. | +| `folder` | Arbitrary media from a [folder](../folders.md), e.g. a Home Assistant media folder. | +| `auto` | Automatically prioritize available media based on camera capabilities. Order of precedence: `reviews` > `clips` > `snapshots` > `recordings`. | + ### Layout Configuration The `layout` block configures the fit and position of the media _within_ the camera dimensions (in order to control the dimensions for the whole card see [the card dimensions configuration](../dimensions.md) ). @@ -182,7 +213,7 @@ cameras: | `fit` | `contain` | If `contain`, the media is contained within the camera container/card and letterboxed if necessary. If `cover`, the media is expanded proportionally (i.e. maintaining the media aspect ratio) until the camera/card dimensions are fully covered. If `fill`, the media is stretched to fill the camera/card dimensions (i.e. ignoring the media aspect ratio). See [CSS object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) for technical details and a visualization. Note that if `aspect_ratio` is also set, this is controlling the behavior "within" that aspect-ratio, otherwise it's within the container for the camera (which is effectively the whole card for single card configurations). | | `pan` | | A dictionary that may contain an `x` and `y` percentage (`0` - `100`) to control the position of the media when "digitally zoomed in" (see `zoom` parameter). This can be effectively used to "pan"/cut the media shown. A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See visualizations below. | | `position` | | A dictionary that may contain an `x` and `y` percentage (`0` - `100`) to control the position of the media when the fit is `cover` (for other values of `fit` this option has no effect). This can be effectively used to "pan"/cut the media shown. At any given time, only one of `x` and `y` will have an effect, depending on whether media width is larger than the camera/card dimensions (in which case `x` controls the position) or the media height is larger than the camera/card dimensions (in which case `y` controls the position). A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See [CSS object-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) for technicals. See visualizations below. | -| `view_box` | | A dictionary that may contain a `top`, `bottom`, `left` and `right` percentage (`0` - `100`) to precisely crop what part of the media to show by specifying a % inset value from each side. Browsers apply this cropping after `position` and `fit` have been applied. Unlike `zoom`, the user cannot dynamically zoom back out -- however the builtin media controls will work as normal. See visualizations below. Limited [browser support](https://caniuse.com/mdn-css_properties_object-view-box): ![](../../images/browsers/chrome_16x16.png 'Google Chrome :no-zoom') ![](../../images/browsers/chromium_16x16.png 'Chromium :no-zoom') ![](../../images/browsers/edge_16x16.png 'Microsoft Edge :no-zoom') | +| `view_box` | | A dictionary that may contain a `top`, `bottom`, `left` and `right` percentage (`0` - `100`) to precisely crop what part of the media to show by specifying a % inset value from each side. Browsers apply this cropping after `position` and `fit` have been applied. Unlike `zoom`, the user cannot dynamically zoom back out -- however the builtin media controls will work as normal. See visualizations below. Limited [browser support](https://caniuse.com/mdn-css_properties_object-view-box): ![Chrome](../../images/browsers/chrome_16x16.png 'Google Chrome :no-zoom') ![Chromium](../../images/browsers/chromium_16x16.png 'Chromium :no-zoom') ![Edge](../../images/browsers/edge_16x16.png 'Microsoft Edge :no-zoom') | | `zoom` | `1.0` | A value between `1.0` and `10.0` inclusive that defines how much additional "digital zoom" to apply to this camera by default. Unlike with `view_box` the user can easily "zoom back out". Often used in conjuction with `pan`. When zoomed in the [builtin browser media controls](../live.md?id=controls) will automatically be disabled (as otherwise they would be enlarged also). | > [!NOTE] @@ -358,6 +389,26 @@ cameras: | `events` | `[events, clips, snapshots]` | Whether to trigger the camera when `events` occur (whether or not media is available) or whenever updated `clips` or `snapshots` are detected. Detection support varies by camera [engine](engine.md). | | `motion` | `false` | Whether to not to trigger the camera by automatically detecting and using the motion `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the motion `binary_sensor` entity has been enabled in Home Assistant. | | `occupancy` | `false` | Whether to not to trigger the camera by automatically detecting and using the occupancy `binary_sensor` for this camera and its configured zones and labels. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant. If this camera has configured zones, only occupancy sensors for those zones are used -- if the overall _camera_ occupancy sensor is also required, it can be manually added to `entities`. If this camera has configured labels, only occupancy sensors for those labels are used. | +| `reviews` | | Configuration for triggering on Frigate review items. See below. Frigate-specific. | + +### `reviews` + +The `reviews` block configures triggering based on review items (e.g., alerts and detections from Frigate). + +```yaml +cameras: + - camera_entity: camera.office + triggers: + reviews: + severities: + - high + description: true +``` + +| Option | Default | Description | +| ------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `severities` | `[high]` | An array of severity levels to trigger on. Possible values: `high` (equivalent to Frigate alerts), `medium` (equivalent to Frigate detections), `low`. At least one severity must be configured for review triggers to be active. | +| `description` | `true` | Whether to trigger on review description updates (e.g. Frigate GenAI descriptions). Severity must also match. | ## Fully expanded reference @@ -378,6 +429,14 @@ cameras: occupancy: true entities: - binary_sensor.front_door_sensor + events: + - events + - clips + - snapshots + reviews: + severities: + - high + description: true cast: method: standard dimensions: diff --git a/docs/configuration/cameras/live-provider.md b/docs/configuration/cameras/live-provider.md index 1081b7e0..598c431a 100644 --- a/docs/configuration/cameras/live-provider.md +++ b/docs/configuration/cameras/live-provider.md @@ -88,11 +88,11 @@ cameras: # [...] ``` -| Option | Default | Description | -| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `entity` | | The RTSP camera entity to pass to the WebRTC Card for this camera. | -| `url` | Depends on the camera engine (e.g. Frigate cameras will automatically use the camera name since this is the [recommended setup](https://deploy-preview-4055--frigate-docs.netlify.app/guides/configuring_go2rtc/)). | The RTSP url to pass to the WebRTC Card, e.g. `rtsp://USERNAME:PASSWORD@CAMERA:554/RTSP_PATH` | -| `*` | | Any options specified in the `webrtc_card:` YAML dictionary are silently passed through to the AlexxIT's WebRTC Card. See [WebRTC Configuration](https://github.com/AlexxIT/WebRTC#configuration) for full details this external card provides, e.g. `ui: true` will enable the WebRTC Card UI. | +| Option | Default | Description | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `entity` | | The RTSP camera entity to pass to the WebRTC Card for this camera. | +| `url` | Depends on the camera engine (e.g. Frigate cameras will automatically use the camera name since this is the [recommended setup](https://docs.frigate.video/guides/configuring_go2rtc/)). | The RTSP url to pass to the WebRTC Card, e.g. `rtsp://USERNAME:PASSWORD@CAMERA:554/RTSP_PATH` | +| `*` | | Any options specified in the `webrtc_card:` YAML dictionary are silently passed through to the AlexxIT's WebRTC Card. See [WebRTC Configuration](https://github.com/AlexxIT/WebRTC#configuration) for full details this external card provides, e.g. `ui: true` will enable the WebRTC Card UI. | ## Fully expanded reference diff --git a/docs/configuration/folders.md b/docs/configuration/folders.md index 6b5c66e4..dc9175ba 100644 --- a/docs/configuration/folders.md +++ b/docs/configuration/folders.md @@ -12,13 +12,13 @@ folders: # [...] ``` -| Option | Default | Description | -| ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | | An optional folder `id` which can be used by the [`folder` action](./actions/custom/README.md?id=folder) to show a particular folder contents. | -| `ha` | | Options for `ha` folder types. See below. | -| `icon` | | An optional folder icon. | -| `title` | | An optional folder title. | -| `type` | `ha` | The type of folder, `ha` for Home Assistant media folders (currently the only supported type of folder). | +| Option | Default | Description | +| ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | | An optional folder `id` which can be used by [camera `media` configuration](./cameras/README.md#media) or [`folder` actions](./actions/custom/README.md?id=folder) to show a particular folder's contents. | +| `ha` | | Options for `ha` folder types. See below. | +| `icon` | | An optional folder icon. | +| `title` | | An optional folder title. | +| `type` | `ha` | The type of folder, `ha` for Home Assistant media folders (currently the only supported type of folder). | ## `ha` diff --git a/docs/configuration/live.md b/docs/configuration/live.md index 0366e279..7071d549 100644 --- a/docs/configuration/live.md +++ b/docs/configuration/live.md @@ -93,16 +93,15 @@ live: # [...] ``` -| Option | Default | Description | -| ----------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `events_media_type` | `all` | Whether to show `clips`, `snapshots` or `all` in the thumbnail carousel in the `live` view. This setting is only relevant when the `media_type` parameter is set to `events`. | -| `media_type` | `events` | Whether to load `events` or `recordings` media. | -| `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`). | -| `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. | -| `show_download_control` | `true` | Whether to show the download control on each thumbnail. | -| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. | -| `show_timeline_control` | `true` | Whether to show the timeline ('target') control on each thumbnail. | -| `size` | `100` | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `300`. | +| Option | Default | Description | +| ----------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`). | +| `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. | +| `show_download_control` | `false` | Whether to show the download control on each thumbnail. | +| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. | +| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. | +| `show_timeline_control` | `false` | Whether to show the timeline ('target') control on each thumbnail. | +| `size` | `100` | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `300`. | ### `timeline` @@ -118,7 +117,6 @@ live: | Option | Default | Description | | ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `clustering_threshold` | `3` | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. | -| `events_media_type` | `all` | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot. | | `format` | | Configuration for the timeline time & date format. See below. | | `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`). | | `pan_mode` | `pan` | See [timeline pan mode](timeline-pan-mode.md). | @@ -219,20 +217,18 @@ live: # Optionally override the default style. right: 5% thumbnails: - media_type: events - events_media_type: all size: 100 show_details: false - show_download_control: true + show_download_control: false show_favorite_control: true - show_timeline_control: true + show_review_control: true + show_timeline_control: false mode: none timeline: style: ribbon mode: none pan_mode: pan clustering_threshold: 3 - events_media_type: all show_recordings: true window_seconds: 3600 format: diff --git a/docs/configuration/media-gallery.md b/docs/configuration/media-gallery.md index 66f7b0b5..4f6fc885 100644 --- a/docs/configuration/media-gallery.md +++ b/docs/configuration/media-gallery.md @@ -1,7 +1,8 @@ # `media_gallery` The `media_gallery` is used for providing an overview of all `clips`, -`snapshots`, `recordings` and `folder` contents in a thumbnail gallery. +`snapshots`, `recordings`, `reviews` and `folder` contents in a thumbnail +gallery. ```yaml media_gallery: @@ -44,9 +45,10 @@ media_gallery: | Option | Default | Description | | ----------------------- | ------- | -------------------------------------------------------------------------------------------------------- | | `show_details` | `false` | Whether to show media details (e.g. duration, start time, object detected, etc) alongside the thumbnail. | -| `show_download_control` | `true` | Whether to show the download control on each thumbnail. | +| `show_download_control` | `false` | Whether to show the download control on each thumbnail. | | `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. | -| `show_timeline_control` | `true` | Whether to show the timeline ('target') control on each thumbnail. | +| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. | +| `show_timeline_control` | `false` | Whether to show the timeline ('target') control on each thumbnail. | | `size` | `100` | The size of the thumbnails in the gallery. Must be >= `75` and <= `300`. | ## Fully expanded reference @@ -61,9 +63,10 @@ media_gallery: thumbnails: size: 100 show_details: false - show_download_control: true + show_download_control: false show_favorite_control: true - show_timeline_control: true + show_review_control: true + show_timeline_control: false actions: entity: light.office_main_lights tap_action: diff --git a/docs/configuration/media-viewer.md b/docs/configuration/media-viewer.md index a60090b4..cf267269 100644 --- a/docs/configuration/media-viewer.md +++ b/docs/configuration/media-viewer.md @@ -93,9 +93,11 @@ media_viewer: | ----------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`). | | `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. | -| `show_download_control` | `true` | Whether to show the download control on each thumbnail. | +| `show_download_control` | `false` | Whether to show the download control on each thumbnail. | | `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. | -| `show_timeline_control` | `true` | Whether to show the timeline ('target') control on each thumbnail. | +| `show_info_control` | `true` | Whether to show the info ('i') control on each thumbnail. | +| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. | +| `show_timeline_control` | `false` | Whether to show the timeline ('target') control on each thumbnail. | | `size` | `100` | The size of the thumbnails in the thumbnail carousel pixels. Must be >= `75` and <= `300`. | ### `timeline` @@ -112,7 +114,6 @@ media_viewer: | Option | Default | Description | | ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `clustering_threshold` | `3` | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. | -| `events_media_type` | `all` | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot. | | `format` | | Configuration for the timeline time & date format. See below. | | `mode` | `none` | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`). | | `pan_mode` | `pan` | See [timeline pan mode](timeline-pan-mode.md). | @@ -199,15 +200,16 @@ media_viewer: size: 100 mode: none show_details: false - show_download_control: true + show_download_control: false show_favorite_control: true - show_timeline_control: true + show_info_control: true + show_review_control: true + show_timeline_control: false timeline: style: ribbon mode: none pan_mode: pan clustering_threshold: 3 - events_media_type: all show_recordings: true window_seconds: 3600 format: diff --git a/docs/configuration/menu.md b/docs/configuration/menu.md index fbfbb46b..ffa74b4c 100644 --- a/docs/configuration/menu.md +++ b/docs/configuration/menu.md @@ -37,6 +37,7 @@ menu: | `folders` | The `folders` menu button: brings the user to a [gallery](./media-gallery.md) of folders on tap and to the media viewer with media from the folder on hold. Will only appear if [`folders`](./folders.md) are configured. | | `fullscreen` | The `fullscreen` menu button: expand the card to consume the fullscreen. Please note that fullscreen behavior on iPhone is limited, see [troubleshooting](../troubleshooting.md?id=fullscreen-doesn39t-work-on-iphone). | | `image` | The `image` view menu button: brings the user to the static `image` view. | +| `info` | The `info` menu button: show media metadata (e.g. event time, camera, description). | | `iris` | The main Advanced Camera Card `iris` menu button: brings the user to the default configured view (`view.default`), or collapses/expands the menu if the `menu.style` is `hidden` . | | `live` | The `live` view menu button: brings the user to the `live` view. | | `media_player` | The `media_player` menu button: sends the visible media to a remote media player. Supports Frigate clips, snapshots and live camera (only for cameras that specify a `camera_entity` and only using the default HA stream (equivalent to the `ha` live provider)). `jsmpeg` or `webrtc-card` are not supported, although live can still be played as long as `camera_entity` is specified. In the player list, a `tap` will send the media to the player, a `hold` will stop the media on the player. | @@ -50,13 +51,13 @@ menu: ### Options for each button -| Option | Default | Description | -| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `alignment` | `matching` | Whether this menu item should have an alignment that is `matching` the menu alignment or `opposing` the menu alignment. Can be used to create two separate groups of buttons on the menu. The `priority` option orders buttons within a given `alignment`. | -| `enabled` | `true` for `iris`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode` and `ptz_home`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `ptz_controls` | Whether or not to show the button. | -| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. See also [custom icons](../usage/custom-icons.md). | -| `permanent` | `false` | If `false` the menu item is hidden when the menu has the `hidden` style and the menu is closed, otherwise it is shown (and sorted to the front). | -| `priority` | `50` | The menu item priority. Higher priority items are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`). Priority applies separately to `matching` and `opposing` groups (see `alignment` above). Minimum `0`, maximum `100`. | +| Option | Default | Description | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `alignment` | `matching` | Whether this menu item should have an alignment that is `matching` the menu alignment or `opposing` the menu alignment. Can be used to create two separate groups of buttons on the menu. The `priority` option orders buttons within a given `alignment`. | +| `enabled` | `true` for `iris`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode`, `ptz_home` and `info`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `ptz_controls` | Whether or not to show the button. | +| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. See also [custom icons](../usage/custom-icons.md). | +| `permanent` | `false` | If `false` the menu item is hidden when the menu has the `hidden` style and the menu is closed, otherwise it is shown (and sorted to the front). | +| `priority` | `50` | The menu item priority. Higher priority items are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`). Priority applies separately to `matching` and `opposing` groups (see `alignment` above). Minimum `0`, maximum `100`. | ### Additional options: `microphone` @@ -132,6 +133,11 @@ menu: enabled: false alignment: matching icon: mdi:image + info: + priority: 50 + enabled: true + alignment: matching + icon: mdi:information-outline iris: priority: 50 enabled: true diff --git a/docs/configuration/status-bar.md b/docs/configuration/status-bar.md index dc7adf7d..c68cf7e0 100644 --- a/docs/configuration/status-bar.md +++ b/docs/configuration/status-bar.md @@ -28,12 +28,13 @@ status_bar: ### Available Items -| Button name | Description | -| ------------ | ------------------------------------------------------ | -| `engine` | The icon of the camera engine for the relevant camera. | -| `resolution` | The detected media resolution (if any). | -| `technology` | The detected media technology (if any). | -| `title` | The media title. | +| Button name | Description | +| ------------ | ------------------------------------------------------------------------------------------- | +| `engine` | The icon of the camera engine for the relevant camera. | +| `resolution` | The detected media resolution (if any). | +| `severity` | The media severity indicator (if any) for review severity (e.g. Frigate alerts/detections). | +| `technology` | The detected media technology (if any). | +| `title` | The media title. | ### Options for each item @@ -75,6 +76,9 @@ status_bar: resolution: enabled: true priority: 50 + severity: + enabled: true + priority: 50 technology: enabled: true priority: 50 diff --git a/docs/configuration/timeline.md b/docs/configuration/timeline.md index b854b1ed..c587b4fd 100644 --- a/docs/configuration/timeline.md +++ b/docs/configuration/timeline.md @@ -10,7 +10,7 @@ timeline: You can interact with the timeline in a number of ways: -- Clicking on an event will take you to the media viewer for that event. +- Clicking on an event/review will take you to the media viewer for that event/review. - Clicking on the "background", or a camera title, will take you to the recordings for that camera (seeking to the clicked time). - Clicking on the time axis will take you to recordings for all cameras (seeking to the clicked time). @@ -18,7 +18,6 @@ You can interact with the timeline in a number of ways: | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `clustering_threshold` | `3` | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. | | `controls` | | Configuration for the timeline controls. See below. | -| `events_media_type` | `all` | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot. | | `format` | | Configuration for the timeline time & date format. See below. | | `show_recordings` | `true` | Whether to show recordings on the timeline (specifically: which hours have any recorded content). | | `style` | `stack` | Whether the timeline should show events as a single flat `ribbon` or a `stack` of events that are clustered using the `clustering_threshold`. | @@ -69,6 +68,7 @@ timeline: | `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. | | `show_download_control` | `true` | Whether to show the download control on each thumbnail. | | `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. | +| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. | | `show_timeline_control` | `true` | Whether to show the timeline ('target') control on each thumbnail. | | `size` | `100` | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `300`. | @@ -80,7 +80,6 @@ timeline: timeline: style: stack clustering_threshold: 3 - events_media_type: all show_recordings: true window_seconds: 3600 format: @@ -92,5 +91,6 @@ timeline: show_details: true show_download_control: true show_favorite_control: true + show_review_control: true show_timeline_control: true ``` diff --git a/docs/configuration/view.md b/docs/configuration/view.md index f8401ce5..290d7ef8 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -147,6 +147,8 @@ This card supports several different views. | `live` | Shows the live camera view with the configured [live provider](./cameras/live-provider.md). | | `recording` | Shows a viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` menu icon. | | `recordings` | Shows a gallery of recent (last day) recordings for this camera and its dependents. | +| `review` | Shows a viewer for the most recent unreviewed review item (e.g. alerts/detections in Frigate). | +| `reviews` | Shows a viewer for the most recent unreviewed review item (e.g. alerts/detections in Frigate). | | `snapshot` | Shows a viewer for the most recent snapshot for this camera. Can also be accessed by holding down the `snapshots` menu icon. | | `snapshots` | Shows a gallery of snapshots for this camera. | | `timeline` | Shows an event timeline. | diff --git a/markdown-link-check.json b/markdown-link-check.json index 7fba64e4..1f2946a1 100644 --- a/markdown-link-check.json +++ b/markdown-link-check.json @@ -2,6 +2,9 @@ "ignorePatterns": [ { "pattern": "vscode://" + }, + { + "pattern": "https://www.npmjs.com/" } ] } diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts index b1e8d844..c869ed6b 100644 --- a/src/camera-manager/browse-media/engine-browse-media.ts +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -6,6 +6,7 @@ import { getMediaDownloadPath } from '../../ha/download'; import { EntityRegistryManager } from '../../ha/registry/entity/types'; import { ResolvedMediaCache } from '../../ha/resolved-media'; import { HomeAssistant } from '../../ha/types'; +import { QuerySource } from '../../query-source.js'; import { Endpoint } from '../../types'; import { ViewMedia } from '../../view/item'; import { ViewItemCapabilities } from '../../view/types'; @@ -55,6 +56,7 @@ export class BrowseMediaCameraManagerEngine ): EventQuery[] | null { return [ { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: cameraIDs, ...query, diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts index 8814aeb3..69a83b14 100644 --- a/src/camera-manager/engine.ts +++ b/src/camera-manager/engine.ts @@ -8,6 +8,7 @@ import { CameraManagerReadOnlyConfigStore } from './store'; import { CameraManagerCameraMetadata, CameraQuery, + DefaultQueryParameters, Engine, EngineOptions, EventQuery, @@ -17,11 +18,15 @@ import { PartialEventQuery, PartialRecordingQuery, PartialRecordingSegmentsQuery, + PartialReviewQuery, QueryReturnType, + QueryType, RecordingQuery, RecordingQueryResultsMap, RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, + ReviewQuery, + ReviewQueryResultsMap, } from './types'; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; @@ -31,6 +36,15 @@ export interface CameraManagerEngine { createCamera(hass: HomeAssistant, cameraConfig: CameraConfig): Promise; + /** + * Get default query parameters for a camera based on its configuration. + * Engines read their own config and return generic filter params. + */ + getDefaultQueryParameters( + camera: Camera, + queryType: QueryType, + ): DefaultQueryParameters; + generateDefaultEventQuery( store: CameraManagerReadOnlyConfigStore, cameraIDs: Set, @@ -70,6 +84,19 @@ export interface CameraManagerEngine { engineOptions?: EngineOptions, ): Promise; + generateDefaultReviewQuery( + store: CameraManagerReadOnlyConfigStore, + cameraIDs: Set, + query?: PartialReviewQuery, + ): ReviewQuery[] | null; + + getReviews( + hass: HomeAssistant, + store: CameraManagerReadOnlyConfigStore, + query: ReviewQuery, + engineOptions?: EngineOptions, + ): Promise; + generateMediaFromEvents( hass: HomeAssistant, store: CameraManagerReadOnlyConfigStore, @@ -84,6 +111,13 @@ export interface CameraManagerEngine { results: QueryReturnType, ): ViewMedia[] | null; + generateMediaFromReviews( + hass: HomeAssistant, + store: CameraManagerReadOnlyConfigStore, + query: ReviewQuery, + results: QueryReturnType, + ): ViewMedia[] | null; + getMediaDownloadPath( hass: HomeAssistant, cameraConfig: CameraConfig, @@ -97,6 +131,13 @@ export interface CameraManagerEngine { favorite: boolean, ): Promise; + reviewMedia( + hass: HomeAssistant, + cameraConfig: CameraConfig, + media: ViewMedia, + reviewed: boolean, + ): Promise; + getQueryResultMaxAge(query: CameraQuery): number | null; getMediaSeekTime( diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index 3111b673..ad8ff4ed 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -11,6 +11,7 @@ import { Endpoint, PTZCapabilities, PTZMovementType, + SEVERITIES, } from '../../types'; import { errorToConsole } from '../../utils/basic'; import { Camera, CameraInitializationOptions } from '../camera'; @@ -22,18 +23,21 @@ import { getGo2RTCStreamEndpoint, } from '../utils/go2rtc/endpoint'; import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; -import { - FrigateEventWatcherRequest, - FrigateEventWatcherSubscriptionInterface, -} from './event-watcher'; import { getPTZInfo } from './requests'; -import { FrigateEventChange, PTZInfo } from './types'; +import { + FRIGATE_SEVERITY_MAP, + FrigateEventChange, + FrigateReviewChange, + PTZInfo, +} from './types'; +import { FrigateWatcherRequest, FrigateWatcherSubscriptionInterface } from './watcher'; const CAMERA_BIRDSEYE = 'birdseye' as const; interface FrigateCameraInitializationOptions extends CameraInitializationOptions { entityRegistryManager: EntityRegistryManager; - frigateEventWatcher: FrigateEventWatcherSubscriptionInterface; + frigateEventWatcher: FrigateWatcherSubscriptionInterface; + frigateReviewWatcher: FrigateWatcherSubscriptionInterface; } export const isBirdseye = (cameraConfig: CameraConfig): boolean => { @@ -47,6 +51,7 @@ export class FrigateCamera extends Camera { if (this._capabilities?.has('trigger')) { await this._subscribeToEvents(options.hass, options.frigateEventWatcher); + await this._subscribeToReviews(options.hass, options.frigateReviewWatcher); } return this; @@ -183,6 +188,7 @@ export class FrigateCamera extends Camera { clips: !birdseye, snapshots: !birdseye, recordings: !birdseye, + reviews: !birdseye, ...(combinedPTZ && { ptz: combinedPTZ }), }; } @@ -415,7 +421,7 @@ export class FrigateCamera extends Camera { protected async _subscribeToEvents( hass: HomeAssistant, - frigateEventWatcher: FrigateEventWatcherSubscriptionInterface, + frigateEventWatcher: FrigateWatcherSubscriptionInterface, ): Promise { const config = this.getConfig(); if (!config.triggers.events.length || !config.frigate.camera_name) { @@ -424,7 +430,7 @@ export class FrigateCamera extends Camera { /* istanbul ignore next -- exercising the matcher is not possible when the test uses an event watcher -- @preserve */ - const request: FrigateEventWatcherRequest = { + const request: FrigateWatcherRequest = { instanceID: config.frigate.client_id, callback: (event: FrigateEventChange) => this._frigateEventHandler(event), matcher: (event: FrigateEventChange): boolean => @@ -480,4 +486,92 @@ export class FrigateCamera extends Camera { snapshot: snapshotChange && eventsToTriggerOn.includes('snapshots'), }); }; + + protected async _subscribeToReviews( + hass: HomeAssistant, + frigateReviewWatcher: FrigateWatcherSubscriptionInterface, + ): Promise { + const config = this.getConfig(); + const reviewConfig = config.triggers.reviews; + + // Must have at least one severity configured and a camera name to subscribe + if (!reviewConfig.severities.length || !config.frigate.camera_name) { + return; + } + + /* istanbul ignore next -- exercising the matcher is not possible when the + test uses a review watcher -- @preserve */ + const request: FrigateWatcherRequest = { + instanceID: config.frigate.client_id, + callback: (review: FrigateReviewChange) => this._frigateReviewHandler(review), + matcher: (review: FrigateReviewChange): boolean => + review.after.camera === config.frigate.camera_name, + }; + + await frigateReviewWatcher.subscribe(hass, request); + this._onDestroy(() => frigateReviewWatcher.unsubscribe(request)); + } + + protected _frigateReviewHandler = (review: FrigateReviewChange): void => { + const config = this.getConfig(); + const cameraID = this._config.id; + + if (!cameraID) { + return; + } + + if ( + config.frigate.zones?.length && + !config.frigate.zones.some((zone) => review.after.data.zones?.includes(zone)) + ) { + return; + } + + if ( + config.frigate.labels?.length && + !config.frigate.labels.some((label) => review.after.data.objects?.includes(label)) + ) { + return; + } + + const reviewConfig = config.triggers.reviews; + + // Map Frigate severity to card severity. + const cardSeverity = SEVERITIES.find( + (key) => FRIGATE_SEVERITY_MAP[key] === review.after.severity, + ); + + // Check if this is a description update (GenAI added/changed title or scene) + const isDescriptionUpdate = + review.type === 'genai' || + (review.type === 'update' && + (review.after.data.metadata?.title !== review.before.data.metadata?.title || + review.after.data.metadata?.scene !== review.before.data.metadata?.scene || + review.after.data.metadata?.shortSummary !== + review.before.data.metadata?.shortSummary)); + + const shouldTriggerOnSeverity = + cardSeverity && reviewConfig.severities.includes(cardSeverity); + + // Severity must match first - it's the gate condition. + if (!shouldTriggerOnSeverity) { + return; + } + + // For 'update' events, only trigger if description changed (when + // description updates are on). For 'new' and 'end' events, always trigger + // if severity matched + const shouldTriggerOnDescription = reviewConfig.description && isDescriptionUpdate; + + if (review.type === 'update' && !shouldTriggerOnDescription) { + return; + } + + this._eventCallback?.({ + cameraID, + fidelity: 'high', + type: review.type, + review: true, + }); + }; } diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 23b11d94..888cd9ba 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -5,6 +5,7 @@ import { CameraConfig } from '../../config/schema/cameras'; import { getEntityTitle } from '../../ha/get-entity-title'; import { EntityRegistryManager } from '../../ha/registry/entity/types'; import { HomeAssistant } from '../../ha/types'; +import { QuerySource, hasUnsupportedFilters } from '../../query-source.js'; import { Endpoint } from '../../types'; import { allPromises, @@ -29,6 +30,7 @@ import { CameraManagerCameraMetadata, CameraManagerRequestCache, CameraQuery, + DefaultQueryParameters, Engine, EngineOptions, EventQuery, @@ -40,6 +42,7 @@ import { PartialEventQuery, PartialRecordingQuery, PartialRecordingSegmentsQuery, + PartialReviewQuery, QueryResults, QueryResultsType, QueryReturnType, @@ -50,30 +53,38 @@ import { RecordingSegment, RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, + ReviewQuery, + ReviewQueryResultsMap, } from '../types'; import { FrigateCamera, isBirdseye } from './camera'; -import { FrigateEventWatcher } from './event-watcher'; import { FrigateViewMediaFactory } from './media'; import { FrigateViewMediaClassifier } from './media-classifier'; import { NativeFrigateEventQuery, NativeFrigateRecordingSegmentsQuery, + NativeFrigateReviewQuery, getEventSummary, getEvents, getRecordingSegments, getRecordingsSummary, + getReviews, retainEvent, + setReviewsReviewed, } from './requests'; import { + FRIGATE_SEVERITY_MAP, FrigateEventQueryResults, FrigateRecording, FrigateRecordingQueryResults, FrigateRecordingSegmentsQueryResults, + FrigateReviewQueryResults, } from './types'; +import { FrigateEventWatcher, FrigateReviewWatcher } from './watcher'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const MEDIA_METADATA_REQUEST_CACHE_AGE_SECONDS = 60; +const REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS = 60; class FrigateQueryResultsClassifier { public static isFrigateEventQueryResults( @@ -98,6 +109,12 @@ class FrigateQueryResultsClassifier { results.type === QueryResultsType.RecordingSegments ); } + + public static isFrigateReviewQueryResults( + results: QueryResults, + ): results is FrigateReviewQueryResults { + return results.engine === Engine.Frigate && results.type === QueryResultsType.Review; + } } export class FrigateCameraManagerEngine @@ -106,6 +123,7 @@ export class FrigateCameraManagerEngine { protected _entityRegistryManager: EntityRegistryManager; protected _frigateEventWatcher: FrigateEventWatcher; + protected _frigateReviewWatcher: FrigateReviewWatcher; protected _recordingSegmentsCache: RecordingSegmentsCache; protected _requestCache: CameraManagerRequestCache; @@ -126,6 +144,7 @@ export class FrigateCameraManagerEngine super(stateWatcher, eventCallback); this._entityRegistryManager = entityRegistryManager; this._frigateEventWatcher = new FrigateEventWatcher(); + this._frigateReviewWatcher = new FrigateReviewWatcher(); this._recordingSegmentsCache = recordingSegmentsCache; this._requestCache = requestCache; } @@ -146,9 +165,25 @@ export class FrigateCameraManagerEngine entityRegistryManager: this._entityRegistryManager, stateWatcher: this._stateWatcher, frigateEventWatcher: this._frigateEventWatcher, + frigateReviewWatcher: this._frigateReviewWatcher, }); } + public override getDefaultQueryParameters( + camera: Camera, + queryType: QueryType, + ): DefaultQueryParameters { + if (queryType !== QueryType.Event && queryType !== QueryType.Review) { + return {}; + } + + const cameraConfig = camera.getConfig(); + return { + ...(cameraConfig.frigate.labels && { what: new Set(cameraConfig.frigate.labels) }), + ...(cameraConfig.frigate.zones && { where: new Set(cameraConfig.frigate.zones) }), + }; + } + public async getMediaDownloadPath( _hass: HomeAssistant, cameraConfig: CameraConfig, @@ -182,51 +217,10 @@ export class FrigateCameraManagerEngine cameraIDs: Set, query?: PartialEventQuery, ): EventQuery[] | null { - const relevantCameraConfigs = [...store.getCameraConfigs(cameraIDs)]; - - // If all cameras specify exactly the same zones or labels (incl. none), we - // can use a single batch query which will be better performance wise, - // otherwise we must fan out to multiple queries in order to precisely match - // the user's intent. - const uniqueZoneArrays = uniqWith( - relevantCameraConfigs.map((config) => config?.frigate.zones), - isEqual, - ); - const uniqueLabelArrays = uniqWith( - relevantCameraConfigs.map((config) => config?.frigate.labels), - isEqual, - ); - - if (uniqueZoneArrays.length === 1 && uniqueLabelArrays.length === 1) { - return [ - { - type: QueryType.Event, - cameraIDs: cameraIDs, - ...(uniqueLabelArrays[0] && { what: new Set(uniqueLabelArrays[0]) }), - ...(uniqueZoneArrays[0] && { where: new Set(uniqueZoneArrays[0]) }), - ...query, - }, - ]; - } - - const output: EventQuery[] = []; - for (const cameraID of cameraIDs) { - const cameraConfig = store.getCameraConfig(cameraID); - if (cameraConfig) { - output.push({ - type: QueryType.Event, - cameraIDs: new Set([cameraID]), - ...(cameraConfig.frigate.labels && { - what: new Set(cameraConfig.frigate.labels), - }), - ...(cameraConfig.frigate.zones && { - where: new Set(cameraConfig.frigate.zones), - }), - ...query, - }); - } - } - return output.length ? output : null; + return this._generateBatchableQuery(store, cameraIDs, { + type: QueryType.Event, + ...query, + }); } public generateDefaultRecordingQuery( @@ -236,6 +230,7 @@ export class FrigateCameraManagerEngine ): RecordingQuery[] { return [ { + source: QuerySource.Camera, type: QueryType.Recording, cameraIDs: cameraIDs, ...query, @@ -262,6 +257,86 @@ export class FrigateCameraManagerEngine ]; } + public generateDefaultReviewQuery( + store: CameraManagerReadOnlyConfigStore, + cameraIDs: Set, + query?: PartialReviewQuery, + ): ReviewQuery[] | null { + return this._generateBatchableQuery(store, cameraIDs, { + type: QueryType.Review, + ...query, + }); + } + + /** + * Helper to generate batchable queries for events and reviews. + * If all cameras have identical zones/labels config, creates a single batch query. + * Otherwise fans out to per-camera queries. + */ + protected _generateBatchableQuery( + store: CameraManagerReadOnlyConfigStore, + cameraIDs: Set, + query: PartialEventQuery & { type: QueryType.Event }, + ): EventQuery[] | null; + protected _generateBatchableQuery( + store: CameraManagerReadOnlyConfigStore, + cameraIDs: Set, + query: PartialReviewQuery & { type: QueryType.Review }, + ): ReviewQuery[] | null; + protected _generateBatchableQuery( + store: CameraManagerReadOnlyConfigStore, + cameraIDs: Set, + query: (PartialEventQuery | PartialReviewQuery) & { + type: QueryType.Event | QueryType.Review; + }, + ): (EventQuery | ReviewQuery)[] | null { + const relevantCameraConfigs = [...store.getCameraConfigs(cameraIDs)]; + + // If all cameras specify exactly the same zones or labels (incl. none), we + // can use a single batch query which will be better performance wise, + // otherwise we must fan out to multiple queries in order to precisely match + // the user's intent. + const uniqueZoneArrays = uniqWith( + relevantCameraConfigs.map((config) => config?.frigate.zones), + isEqual, + ); + const uniqueLabelArrays = uniqWith( + relevantCameraConfigs.map((config) => config?.frigate.labels), + isEqual, + ); + + if (uniqueZoneArrays.length === 1 && uniqueLabelArrays.length === 1) { + return [ + { + source: QuerySource.Camera, + ...query, + cameraIDs: cameraIDs, + ...(uniqueLabelArrays[0] && { what: new Set(uniqueLabelArrays[0]) }), + ...(uniqueZoneArrays[0] && { where: new Set(uniqueZoneArrays[0]) }), + }, + ]; + } + + const output: (EventQuery | ReviewQuery)[] = []; + for (const cameraID of cameraIDs) { + const cameraConfig = store.getCameraConfig(cameraID); + if (cameraConfig) { + output.push({ + source: QuerySource.Camera, + ...query, + cameraIDs: new Set([cameraID]), + ...(cameraConfig.frigate.labels && { + what: new Set(cameraConfig.frigate.labels), + }), + ...(cameraConfig.frigate.zones && { + where: new Set(cameraConfig.frigate.zones), + }), + }); + } + } + return output.length ? output : null; + } + public async favoriteMedia( hass: HomeAssistant, cameraConfig: CameraConfig, @@ -276,6 +351,24 @@ export class FrigateCameraManagerEngine media.setFavorite(favorite); } + public async reviewMedia( + hass: HomeAssistant, + cameraConfig: CameraConfig, + media: ViewMedia, + reviewed: boolean, + ): Promise { + if (!FrigateViewMediaClassifier.isFrigateReview(media)) { + return; + } + + await setReviewsReviewed( + hass, + cameraConfig.frigate.client_id, + [media.getID()], + reviewed, + ); + } + protected _buildInstanceToCameraIDMapFromQuery( store: CameraManagerReadOnlyConfigStore, cameraIDs: Set, @@ -314,6 +407,17 @@ export class FrigateCameraManagerEngine query: EventQuery, engineOptions?: EngineOptions, ): Promise { + if ( + hasUnsupportedFilters(query, { + favorite: true, + tags: true, + what: true, + where: true, + }) + ) { + return null; + } + const output: EventQueryResultsMap = new Map(); const processInstanceQuery = async ( @@ -374,12 +478,80 @@ export class FrigateCameraManagerEngine return output.size ? output : null; } + public async getReviews( + hass: HomeAssistant, + store: CameraManagerReadOnlyConfigStore, + query: ReviewQuery, + engineOptions?: EngineOptions, + ): Promise { + if (hasUnsupportedFilters(query, { what: true, where: true, reviewed: true })) { + return null; + } + + const output: ReviewQueryResultsMap = new Map(); + + const processInstanceQuery = async ( + instanceID: string, + cameraIDs?: Set, + ): Promise => { + if (!cameraIDs || !cameraIDs.size) { + return; + } + const instanceQuery = { ...query, cameraIDs: cameraIDs }; + const cachedResult = + engineOptions?.useCache ?? true ? this._requestCache.get(instanceQuery) : null; + if (cachedResult) { + output.set(query, cachedResult as FrigateReviewQueryResults); + return; + } + + const nativeQuery: NativeFrigateReviewQuery = { + instance_id: instanceID, + cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(store, cameraIDs)), + ...(query.what && { labels: Array.from(query.what) }), + ...(query.where && { zones: Array.from(query.where) }), + ...(query.end && { before: Math.floor(query.end.getTime() / 1000) }), + ...(query.start && { after: Math.floor(query.start.getTime() / 1000) }), + ...(query.limit && { limit: query.limit }), + ...(query.severity && { severity: FRIGATE_SEVERITY_MAP[query.severity] }), + ...(query.reviewed !== undefined && { reviewed: query.reviewed }), + }; + + const result: FrigateReviewQueryResults = { + type: QueryResultsType.Review, + engine: Engine.Frigate, + instanceID: instanceID, + reviews: await getReviews(hass, nativeQuery), + expiry: add(new Date(), { seconds: REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS }), + cached: false, + }; + + if (engineOptions?.useCache ?? true) { + this._requestCache.set(query, { ...result, cached: true }, result.expiry); + } + output.set(instanceQuery, result); + }; + + const instances = this._buildInstanceToCameraIDMapFromQuery(store, query.cameraIDs); + + await Promise.all( + Array.from(instances.keys()).map((instanceID) => + processInstanceQuery(instanceID, instances.get(instanceID)), + ), + ); + return output.size ? output : null; + } + public async getRecordings( hass: HomeAssistant, store: CameraManagerReadOnlyConfigStore, query: RecordingQuery, engineOptions?: EngineOptions, ): Promise { + if (hasUnsupportedFilters(query)) { + return null; + } + const output: RecordingQueryResultsMap = new Map(); const processQuery = async ( @@ -641,11 +813,50 @@ export class FrigateCameraManagerEngine return output; } + public generateMediaFromReviews( + _hass: HomeAssistant, + store: CameraManagerReadOnlyConfigStore, + query: ReviewQuery, + results: QueryReturnType, + ): ViewMedia[] | null { + if (!FrigateQueryResultsClassifier.isFrigateReviewQueryResults(results)) { + return null; + } + + const output: ViewMedia[] = []; + for (const review of results.reviews) { + const cameraID = this._getCameraIDMatch( + store, + query, + results.instanceID, + review.camera, + ); + if (!cameraID) { + continue; + } + const cameraConfig = this._getQueryableCameraConfig(store, cameraID); + if (!cameraConfig) { + continue; + } + const media = FrigateViewMediaFactory.createReviewViewMedia( + cameraID, + review, + cameraConfig, + ); + if (media) { + output.push(media); + } + } + return output; + } + public getQueryResultMaxAge(query: CameraQuery): number | null { if (query.type === QueryType.Event) { return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS; } else if (query.type === QueryType.Recording) { return RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS; + } else if (query.type === QueryType.Review) { + return REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS; } return null; } @@ -657,11 +868,22 @@ export class FrigateCameraManagerEngine target: Date, engineOptions?: EngineOptions, ): Promise { - const start = media.getStartTime(); - const end = media.getEndTime(); + const mediaStart = media.getStartTime(); + const mediaEnd = media.getEndTime(); const cameraID = media.getCameraID(); + const mediaType = media.getMediaType(); - if (!start || !end || target < start || target > end || !cameraID) { + if (!mediaStart || !cameraID) { + return null; + } + + // For recordings/reviews, use hour boundaries since Frigate recordings are + // hour-long. For clips/snapshots, use the actual media time range. + const isRecordingBased = mediaType === 'recording' || mediaType === 'review'; + const start = isRecordingBased ? startOfHour(mediaStart) : mediaStart; + const end = isRecordingBased ? endOfHour(mediaStart) : mediaEnd; + + if (!end || target < start || target > end) { return null; } @@ -762,6 +984,7 @@ export class FrigateCameraManagerEngine hass, store, { + source: QuerySource.Camera, type: QueryType.Recording, cameraIDs: cameraIDs, }, @@ -822,6 +1045,7 @@ export class FrigateCameraManagerEngine ): Promise { const cameraIDs = this._recordingSegmentsCache.getCameraIDs(); const recordingQuery: RecordingQuery = { + source: QuerySource.Camera, cameraIDs: new Set(cameraIDs), type: QueryType.Recording, }; diff --git a/src/camera-manager/frigate/event-watcher.ts b/src/camera-manager/frigate/event-watcher.ts deleted file mode 100644 index 6a66fc74..00000000 --- a/src/camera-manager/frigate/event-watcher.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { HomeAssistant } from '../../ha/types'; -import { FrigateEventChange, frigateEventChangeSchema } from './types'; - -export interface FrigateEventWatcherRequest { - instanceID: string; - matcher?(event: FrigateEventChange): boolean; - callback(event: FrigateEventChange): void; -} - -export interface FrigateEventWatcherSubscriptionInterface { - subscribe(hass: HomeAssistant, request: FrigateEventWatcherRequest): Promise; - unsubscribe(callback: FrigateEventWatcherRequest): void; -} - -type SubscriptionUnsubscribe = () => Promise; - -export class FrigateEventWatcher implements FrigateEventWatcherSubscriptionInterface { - protected _requests: FrigateEventWatcherRequest[] = []; - protected _unsubscribeCallback: Record = {}; - - public async subscribe( - hass: HomeAssistant, - request: FrigateEventWatcherRequest, - ): Promise { - const shouldSubscribe = !this._hasSubscribers(request.instanceID); - this._requests.push(request); - if (shouldSubscribe) { - this._unsubscribeCallback[request.instanceID] = - await hass.connection.subscribeMessage( - (data) => this._receiveHandler(request.instanceID, data), - { type: 'frigate/events/subscribe', instance_id: request.instanceID }, - ); - } - } - - public async unsubscribe(request: FrigateEventWatcherRequest): Promise { - this._requests = this._requests.filter( - (existingRequest) => existingRequest !== request, - ); - - if (!this._hasSubscribers(request.instanceID)) { - await this._unsubscribeCallback[request.instanceID](); - delete this._unsubscribeCallback[request.instanceID]; - } - } - - protected _hasSubscribers(instanceID: string): boolean { - return !!this._requests.filter((request) => request.instanceID === instanceID) - .length; - } - - protected _receiveHandler(instanceID: string, data: string): void { - let json: unknown; - try { - json = JSON.parse(data); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { - console.warn('Received non-JSON payload as Frigate event', data); - return; - } - - const parsedEvent = frigateEventChangeSchema.safeParse(json); - if (!parsedEvent.success) { - console.warn('Received malformed Frigate event from Home Assistant', data); - return; - } - - for (const request of this._requests) { - if ( - request.instanceID === instanceID && - (!request.matcher || request.matcher(parsedEvent.data)) - ) { - request.callback(parsedEvent.data); - } - } - } -} diff --git a/src/camera-manager/frigate/media-classifier.ts b/src/camera-manager/frigate/media-classifier.ts index 1115a673..de909261 100644 --- a/src/camera-manager/frigate/media-classifier.ts +++ b/src/camera-manager/frigate/media-classifier.ts @@ -1,11 +1,22 @@ import { ViewMedia } from '../../view/item'; -import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media'; +import { + FrigateEventViewMedia, + FrigateRecordingViewMedia, + FrigateReviewViewMedia, +} from './media'; export class FrigateViewMediaClassifier { public static isFrigateMedia( media: ViewMedia, - ): media is FrigateEventViewMedia | FrigateRecordingViewMedia { - return this.isFrigateEvent(media) || this.isFrigateRecording(media); + ): media is + | FrigateEventViewMedia + | FrigateRecordingViewMedia + | FrigateReviewViewMedia { + return ( + this.isFrigateEvent(media) || + this.isFrigateRecording(media) || + this.isFrigateReview(media) + ); } public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia { return media instanceof FrigateEventViewMedia; @@ -15,4 +26,7 @@ export class FrigateViewMediaClassifier { ): media is FrigateRecordingViewMedia { return media instanceof FrigateRecordingViewMedia; } + public static isFrigateReview(media: ViewMedia): media is FrigateReviewViewMedia { + return media instanceof FrigateReviewViewMedia; + } } diff --git a/src/camera-manager/frigate/media.ts b/src/camera-manager/frigate/media.ts index 471bf9f9..56ce4eb9 100644 --- a/src/camera-manager/frigate/media.ts +++ b/src/camera-manager/frigate/media.ts @@ -1,14 +1,16 @@ import { fromUnixTime } from 'date-fns'; import { isEqual } from 'lodash-es'; import { CameraConfig } from '../../config/schema/cameras'; +import { Severity } from '../../types'; import { EventViewMedia, RecordingViewMedia, + ReviewViewMedia, VideoContentType, ViewMedia, ViewMediaType, } from '../../view/item'; -import { FrigateEvent, FrigateRecording } from './types'; +import { FrigateEvent, FrigateRecording, FrigateReview } from './types'; import { getEventMediaContentID, getEventThumbnailURL, @@ -16,6 +18,10 @@ import { getRecordingID, getRecordingMediaContentID, getRecordingTitle, + getReviewMediaContentID, + getReviewSeverity, + getReviewThumbnailURL, + getReviewTitle, } from './util'; export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia { @@ -66,6 +72,9 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia { public getTitle(): string | null { return getEventTitle(this._event); } + public getDescription(): string | null { + return this._event.data?.description ?? null; + } public getThumbnail(): string | null { return this._thumbnail; } @@ -146,6 +155,74 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie } } +export class FrigateReviewViewMedia extends ViewMedia implements ReviewViewMedia { + protected _review: FrigateReview; + protected _contentID: string; + protected _thumbnail: string | null; + protected _title: string; + + constructor( + cameraID: string, + review: FrigateReview, + contentID: string, + thumbnail: string | null, + ) { + super(ViewMediaType.Review, { cameraID }); + this._review = review; + this._contentID = contentID; + this._thumbnail = thumbnail; + this._title = this._review.data.metadata?.title ?? getReviewTitle(review); + } + + public getID(): string { + return this._review.id; + } + public getStartTime(): Date { + return fromUnixTime(this._review.start_time); + } + public getEndTime(): Date | null { + return this._review.end_time ? fromUnixTime(this._review.end_time) : null; + } + public inProgress(): boolean | null { + return !this.getEndTime(); + } + public getVideoContentType(): VideoContentType | null { + return VideoContentType.HLS; + } + public getContentID(): string | null { + return this._contentID; + } + public getTitle(): string | null { + return this._review.data.metadata?.title ?? this._title; + } + public getDescription(): string | null { + return ( + this._review.data.metadata?.scene ?? + this._review.data.metadata?.shortSummary ?? + null + ); + } + public getThumbnail(): string | null { + return this._thumbnail; + } + public getSeverity(): Severity | null { + return getReviewSeverity(this._review.severity); + } + public isReviewed(): boolean { + return !!this._review.has_been_reviewed; + } + public setReviewed(reviewed: boolean): void { + this._review.has_been_reviewed = reviewed; + } + public getWhat(): string[] | null { + return this._review.data.objects ?? null; + } + public getWhere(): string[] | null { + const zones = this._review.data.zones; + return zones?.length ? zones : null; + } +} + export class FrigateViewMediaFactory { static createEventViewMedia( mediaType: ViewMediaType, @@ -201,4 +278,25 @@ export class FrigateViewMediaFactory { getRecordingTitle(cameraTitle, recording), ); } + + static createReviewViewMedia( + cameraID: string, + review: FrigateReview, + cameraConfig: CameraConfig, + ): FrigateReviewViewMedia | null { + if (!cameraConfig.frigate.client_id || !cameraConfig.frigate.camera_name) { + return null; + } + + return new FrigateReviewViewMedia( + cameraID, + review, + getReviewMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + review, + ), + getReviewThumbnailURL(cameraConfig.frigate.client_id, review), + ); + } } diff --git a/src/camera-manager/frigate/requests.ts b/src/camera-manager/frigate/requests.ts index 6f045394..692b9221 100644 --- a/src/camera-manager/frigate/requests.ts +++ b/src/camera-manager/frigate/requests.ts @@ -8,6 +8,8 @@ import { eventSummarySchema, FrigateEvent, frigateEventsSchema, + FrigateReview, + frigateReviewsSchema, PTZInfo, ptzInfoSchema, recordingSegmentsSchema, @@ -15,6 +17,8 @@ import { recordingSummarySchema, RetainResult, retainResultSchema, + ReviewResult, + reviewResultSchema, } from './types'; /** @@ -176,3 +180,65 @@ export const getPTZInfo = async ( true, ); }; + +export interface NativeFrigateReviewQuery { + instance_id: string; + cameras?: string[]; + labels?: string[]; + zones?: string[]; + severity?: string; + after?: number; + before?: number; + limit?: number; + reviewed?: boolean; +} + +/** + * Get review items over websocket. May throw. + * @param hass The Home Assistant object. + * @param params The review search parameters. + * @returns An array of 'FrigateReview's. + */ +export const getReviews = async ( + hass: HomeAssistant, + params: NativeFrigateReviewQuery, +): Promise => { + return await homeAssistantWSRequest( + hass, + frigateReviewsSchema, + { + type: 'frigate/reviews/get', + ...params, + }, + true, + ); +}; + +export const setReviewsReviewed = async ( + hass: HomeAssistant, + instance_id: string, + ids: string[], + reviewed?: boolean, +): Promise => { + const request = { + type: 'frigate/reviews/viewed', + instance_id, + ids, + + // Frigate uses 'viewed' to mean the review has been reviewed. + ...(reviewed !== undefined && { viewed: reviewed }), + }; + + const response = await homeAssistantWSRequest( + hass, + reviewResultSchema, + request, + ); + + if (!response.success) { + throw new AdvancedCameraCardError(localize('error.failed_response'), { + request: request, + response: response, + }); + } +}; diff --git a/src/camera-manager/frigate/types.ts b/src/camera-manager/frigate/types.ts index 77515ff4..31dc034f 100644 --- a/src/camera-manager/frigate/types.ts +++ b/src/camera-manager/frigate/types.ts @@ -5,6 +5,7 @@ import { EventQueryResults, RecordingQueryResults, RecordingSegmentsQueryResults, + ReviewQueryResults, } from '../types'; const dayStringToDate = (arg: unknown): Date | unknown => { @@ -24,6 +25,12 @@ export const eventSchema = z.object({ top_score: z.number().nullable(), zones: z.string().array(), retain_indefinitely: z.boolean().optional(), + data: z + .object({ + // GenAI-generated text description of the object/event + description: z.string().optional(), + }) + .optional(), }); export const frigateEventsSchema = eventSchema.array(); @@ -57,6 +64,12 @@ export const retainResultSchema = z.object({ }); export type RetainResult = z.infer; +export const reviewResultSchema = z.object({ + success: z.boolean(), + message: z.string(), +}); +export type ReviewResult = z.infer; + export interface FrigateRecording { cameraID: string; startTime: Date; @@ -124,3 +137,60 @@ export interface FrigateRecordingSegmentsQueryResults engine: Engine.Frigate; instanceID: string; } + +// ============= +// Review Types +// ============= + +// Maps card severity to Frigate severity +export const FRIGATE_SEVERITY_MAP = { + high: 'alert', + medium: 'detection', + low: 'significant_motion', +} as const; + +export type FrigateReviewSeverity = + (typeof FRIGATE_SEVERITY_MAP)[keyof typeof FRIGATE_SEVERITY_MAP]; + +// Review data schema (only fields we need for display) +const frigateReviewDataSchema = z.object({ + objects: z.string().array().optional(), + zones: z.string().array().optional(), + metadata: z + .object({ + title: z.string().optional(), + scene: z.string().optional(), + shortSummary: z.string().optional(), + }) + .nullable() + .optional(), +}); + +// Review item schema +const frigateReviewSchema = z.object({ + id: z.string(), + camera: z.string(), + severity: z.enum(['alert', 'detection', 'significant_motion']), + start_time: z.number(), + end_time: z.number().nullable(), + thumb_path: z.string().nullable(), + has_been_reviewed: z.boolean().optional(), + data: frigateReviewDataSchema, +}); +export const frigateReviewsSchema = frigateReviewSchema.array(); + +export type FrigateReview = z.infer; + +// Review change schema for live WebSocket updates +export const frigateReviewChangeSchema = z.object({ + before: frigateReviewSchema, + after: frigateReviewSchema, + type: z.enum(['new', 'update', 'end', 'genai']), +}); +export type FrigateReviewChange = z.infer; + +export interface FrigateReviewQueryResults extends ReviewQueryResults { + engine: Engine.Frigate; + instanceID: string; + reviews: FrigateReview[]; +} diff --git a/src/camera-manager/frigate/util.ts b/src/camera-manager/frigate/util.ts index 0305b176..9a51da76 100644 --- a/src/camera-manager/frigate/util.ts +++ b/src/camera-manager/frigate/util.ts @@ -1,8 +1,13 @@ import { toZonedTime } from 'date-fns-tz'; import { CameraConfig } from '../../config/schema/cameras'; -import { ClipsOrSnapshots } from '../../types'; +import { Severity } from '../../types'; import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; -import { FrigateEvent, FrigateRecording } from './types'; +import { + FrigateEvent, + FrigateRecording, + FrigateReview, + FrigateReviewSeverity, +} from './types'; /** * Given an event generate a title. @@ -51,36 +56,36 @@ export const getEventMediaContentID = ( clientId: string, cameraName: string, event: FrigateEvent, - mediaType: ClipsOrSnapshots, + mediaType: 'clips' | 'snapshots', ): string => { return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`; }; /** - * Generate a recording identifier. - * @param clientId The Frigate client id. - * @param cameraName The Frigate camera name. - * @param recording The Frigate recording. - * @returns A recording identifier. + * Build a recording media content ID from a start time. + */ +const buildRecordingMediaContentID = ( + clientId: string, + cameraName: string, + startTime: Date, +): string => + [ + 'media-source://frigate', + clientId, + 'recordings', + cameraName, + `${startTime.getFullYear()}-${String(startTime.getMonth() + 1).padStart(2, '0')}-${String(startTime.getDate()).padStart(2, '0')}`, + String(startTime.getHours()).padStart(2, '0'), + ].join('/'); + +/** + * Generate a recording media content ID. */ export const getRecordingMediaContentID = ( clientId: string, cameraName: string, recording: FrigateRecording, -): string => { - return [ - 'media-source://frigate', - clientId, - 'recordings', - cameraName, - `${recording.startTime.getFullYear()}-${String( - recording.startTime.getMonth() + 1, - ).padStart(2, '0')}-${String( - String(recording.startTime.getDate()).padStart(2, '0'), - )}`, - String(recording.startTime.getHours()).padStart(2, '0'), - ].join('/'); -}; +): string => buildRecordingMediaContentID(clientId, cameraName, recording.startTime); /** * Get a recording ID for internal de-duping. @@ -96,3 +101,65 @@ export const getRecordingID = ( cameraConfig.frigate.camera_name ?? '' }/${recording.startTime.getTime()}/${recording.endTime.getTime()}`; }; + +/** + * Given a review generate a title. + * @param review The Frigate review item. + */ +export const getReviewTitle = (review: FrigateReview): string => { + const objects = review.data.objects?.length + ? review.data.objects.map((o) => prettifyTitle(o)).join(', ') + : ''; + + if (objects) { + return objects; + } + + const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const durationSeconds = Math.round( + review.end_time + ? review.end_time - review.start_time + : Date.now() / 1000 - review.start_time, + ); + + return `${formatDateAndTime( + toZonedTime(review.start_time * 1000, localTimezone), + )} [${durationSeconds}s${objects}]`; +}; + +/** + * Generate a review media content ID. + */ +export const getReviewMediaContentID = ( + clientId: string, + cameraName: string, + review: FrigateReview, +): string => + buildRecordingMediaContentID(clientId, cameraName, new Date(review.start_time * 1000)); + +/** + * Get a thumbnail URL for a review. + */ +export const getReviewThumbnailURL = ( + clientId: string, + review: FrigateReview, +): string | null => { + if (!review.thumb_path) { + return null; + } + const path = review.thumb_path.replace('/media/frigate/', ''); + return `/api/frigate/${clientId}/${path}`; +}; + +/** + * Get generic review severity. + */ +export const getReviewSeverity = (severity: FrigateReviewSeverity): Severity => { + if (severity === 'alert') { + return 'high'; + } + if (severity === 'detection') { + return 'medium'; + } + return 'low'; +}; diff --git a/src/camera-manager/frigate/watcher.ts b/src/camera-manager/frigate/watcher.ts new file mode 100644 index 00000000..b49b2a7b --- /dev/null +++ b/src/camera-manager/frigate/watcher.ts @@ -0,0 +1,108 @@ +import { z } from 'zod'; +import { HomeAssistant } from '../../ha/types'; +import { + FrigateEventChange, + FrigateReviewChange, + frigateEventChangeSchema, + frigateReviewChangeSchema, +} from './types'; + +// Generic request interface for Frigate watchers +export interface FrigateWatcherRequest { + instanceID: string; + matcher?(item: T): boolean; + callback(item: T): void; +} + +// Generic subscription interface +export interface FrigateWatcherSubscriptionInterface { + subscribe(hass: HomeAssistant, request: FrigateWatcherRequest): Promise; + unsubscribe(request: FrigateWatcherRequest): void; +} + +type SubscriptionUnsubscribe = () => Promise; + +/** + * Base class for Frigate WebSocket watchers. + * Handles subscription management and message routing to callbacks. + */ +abstract class FrigateWatcher implements FrigateWatcherSubscriptionInterface { + protected abstract _type: string; + protected abstract _schema: z.ZodType; + + protected _requests: FrigateWatcherRequest[] = []; + protected _unsubscribeCallback: Record = {}; + + public async subscribe( + hass: HomeAssistant, + request: FrigateWatcherRequest, + ): Promise { + const shouldSubscribe = !this._hasSubscribers(request.instanceID); + this._requests.push(request); + if (shouldSubscribe) { + this._unsubscribeCallback[request.instanceID] = + await hass.connection.subscribeMessage( + (data) => this._receiveHandler(request.instanceID, data), + { type: this._type, instance_id: request.instanceID }, + ); + } + } + + public async unsubscribe(request: FrigateWatcherRequest): Promise { + this._requests = this._requests.filter( + (existingRequest) => existingRequest !== request, + ); + + if (!this._hasSubscribers(request.instanceID)) { + await this._unsubscribeCallback[request.instanceID](); + delete this._unsubscribeCallback[request.instanceID]; + } + } + + protected _hasSubscribers(instanceID: string): boolean { + return !!this._requests.filter((request) => request.instanceID === instanceID) + .length; + } + + protected _receiveHandler(instanceID: string, data: string): void { + let json: unknown; + try { + json = JSON.parse(data); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + console.warn(`Received non-JSON payload from subscription: ${this._type}`, data); + return; + } + + const parseResult = this._schema.safeParse(json); + if (!parseResult.success) { + console.warn(`Received malformed message from subscription: ${this._type}`, data); + return; + } + + for (const request of this._requests) { + if ( + request.instanceID === instanceID && + (!request.matcher || request.matcher(parseResult.data)) + ) { + request.callback(parseResult.data); + } + } + } +} + +/** + * Watcher for Frigate event updates via WebSocket. + */ +export class FrigateEventWatcher extends FrigateWatcher { + protected _type = 'frigate/events/subscribe'; + protected _schema = frigateEventChangeSchema; +} + +/** + * Watcher for Frigate review updates via WebSocket. + */ +export class FrigateReviewWatcher extends FrigateWatcher { + protected _type = 'frigate/reviews/subscribe'; + protected _schema = frigateReviewChangeSchema; +} diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index 18a799d1..c8e38a39 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -14,6 +14,7 @@ import { CameraEventCallback, CameraManagerCameraMetadata, CameraQuery, + DefaultQueryParameters, Engine, EngineOptions, EventQuery, @@ -23,11 +24,15 @@ import { PartialEventQuery, PartialRecordingQuery, PartialRecordingSegmentsQuery, + PartialReviewQuery, QueryReturnType, + QueryType, RecordingQuery, RecordingQueryResultsMap, RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, + ReviewQuery, + ReviewQueryResultsMap, } from '../types'; import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config'; import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; @@ -67,6 +72,13 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { }); } + public getDefaultQueryParameters( + _camera: Camera, + _queryType: QueryType, + ): DefaultQueryParameters { + return {}; + } + public generateDefaultEventQuery( _store: CameraManagerReadOnlyConfigStore, _cameraIDs: Set, @@ -118,6 +130,23 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { return null; } + public generateDefaultReviewQuery( + _store: CameraManagerReadOnlyConfigStore, + _cameraIDs: Set, + _query?: PartialReviewQuery, + ): ReviewQuery[] | null { + return null; + } + + public async getReviews( + _hass: HomeAssistant, + _store: CameraManagerReadOnlyConfigStore, + _query: ReviewQuery, + _engineOptions?: EngineOptions, + ): Promise { + return null; + } + public generateMediaFromEvents( _hass: HomeAssistant, _store: CameraManagerReadOnlyConfigStore, @@ -136,6 +165,15 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { return null; } + public generateMediaFromReviews( + _hass: HomeAssistant, + _store: CameraManagerReadOnlyConfigStore, + _query: ReviewQuery, + _results: QueryReturnType, + ): ViewMedia[] | null { + return null; + } + public async getMediaDownloadPath( _hass: HomeAssistant, _cameraConfig: CameraConfig, @@ -153,6 +191,15 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { return; } + public async reviewMedia( + _hass: HomeAssistant, + _cameraConfig: CameraConfig, + _media: ViewMedia, + _reviewed: boolean, + ): Promise { + return; + } + public getQueryResultMaxAge(_query: CameraQuery): number | null { return null; } diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 309e1cdc..cb2a1f56 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -35,6 +35,7 @@ import { CameraEndpointsContext, CameraManagerCameraMetadata, CameraQuery, + DefaultQueryParameters, Engine, EngineOptions, EventQuery, @@ -49,6 +50,7 @@ import { PartialQueryConcreteType, PartialRecordingQuery, PartialRecordingSegmentsQuery, + PartialReviewQuery, QueryResults, QueryResultsType, QueryReturnType, @@ -60,6 +62,8 @@ import { RecordingSegmentsQueryResults, RecordingSegmentsQueryResultsMap, ResultsMap, + ReviewQuery, + ReviewQueryResults, } from './types.js'; export class CameraQueryClassifier { @@ -83,6 +87,11 @@ export class CameraQueryClassifier { ): query is MediaMetadataQuery { return query.type === QueryType.MediaMetadata; } + public static isReviewQuery( + query: CameraQuery | PartialCameraQuery, + ): query is ReviewQuery { + return query.type === QueryType.Review; + } } export class QueryResultClassifier { @@ -106,9 +115,14 @@ export class QueryResultClassifier { ): queryResults is MediaMetadataQueryResults { return queryResults?.type === QueryResultsType.MediaMetadata; } + public static isReviewQueryResult( + queryResults?: QueryResults | null, + ): queryResults is ReviewQueryResults { + return queryResults?.type === QueryResultsType.Review; + } } -export interface ExtendedMediaQueryResult { +interface ExtendedMediaQueryResult { queries: T[]; results: ViewItem[]; } @@ -308,6 +322,17 @@ export class CameraManager { }); } + public getDefaultQueryParameters( + cameraID: string, + queryType: QueryType, + ): DefaultQueryParameters { + const camera = this._store.getCamera(cameraID); + if (!camera) { + return {}; + } + return camera.getEngine().getDefaultQueryParameters(camera, queryType); + } + public generateDefaultRecordingQueries( cameraIDs: string | Set, partialQuery?: PartialRecordingQuery, @@ -328,6 +353,16 @@ export class CameraManager { }); } + public generateDefaultReviewQueries( + cameraIDs: string | Set, + partialQuery?: PartialReviewQuery, + ): ReviewQuery[] | null { + return this._generateDefaultQueries(cameraIDs, { + type: QueryType.Review, + ...partialQuery, + }); + } + protected _generateDefaultQueries( cameraIDs: string | Set, partialQuery: PQT, @@ -356,6 +391,12 @@ export class CameraManager { cameraIDs, partialQuery, ); + } else if (CameraQueryClassifier.isReviewQuery(partialQuery)) { + queries = engine.generateDefaultReviewQuery( + this._store, + cameraIDs, + partialQuery, + ); } for (const query of queries ?? []) { @@ -568,6 +609,33 @@ export class CameraManager { ); } + public async reviewMedia(media: ViewMedia, reviewed: boolean): Promise { + const cameraConfig = this._store.getCameraConfigForMedia(media); + const engine = this._store.getEngineForMedia(media); + const hass = this._api.getHASSManager().getHASS(); + + if (!cameraConfig || !engine || !hass) { + return; + } + + const queryStartTime = new Date(); + + await this._requestLimit.add(() => + engine.reviewMedia(hass, cameraConfig, media, reviewed), + ); + + log( + this._api.getConfigManager().getCardWideConfig(), + 'Advanced Camera Card CameraManager review media request (', + `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, + 'Media:', + media.getID(), + ', Reviewed:', + reviewed, + ')', + ); + } + public areMediaQueriesResultsFresh( resultsTimestamp: Date, queries: T[] | null, @@ -667,6 +735,13 @@ export class CameraManager { query, engineOptions, )) as Map> | null; + } else if (CameraQueryClassifier.isReviewQuery(query)) { + engineResult = (await engine.getReviews( + hass, + this._store, + query, + engineOptions, + )) as Map> | null; } engineResult?.forEach((value, key) => results.set(key, value)); @@ -739,6 +814,11 @@ export class CameraManager { QueryResultClassifier.isRecordingQueryResult(result) ) { media = engine.generateMediaFromRecordings(hass, this._store, query, result); + } else if ( + CameraQueryClassifier.isReviewQuery(query) && + QueryResultClassifier.isReviewQueryResult(result) + ) { + media = engine.generateMediaFromReviews(hass, this._store, query, result); } if (media) { mediaArray.push(...media); diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index e56386a9..50a9d979 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -14,6 +14,7 @@ import { import { BrowseMediaStep, BrowseMediaTarget } from '../../ha/browse-media/walker'; import { isMediaWithinDates } from '../../ha/browse-media/within-dates'; import { HomeAssistant } from '../../ha/types'; +import { hasUnsupportedFilters } from '../../query-source.js'; import { allPromises, formatDate, isValidDate } from '../../utils/basic'; import { ViewMedia } from '../../view/item'; import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media'; @@ -224,8 +225,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine query: EventQuery, engineOptions?: EngineOptions, ): Promise { - // MotionEye does not support these query types and they will never match. - if (query.favorite || query.tags?.size || query.what?.size || query.where?.size) { + if (hasUnsupportedFilters(query)) { return null; } diff --git a/src/camera-manager/reolink/engine-reolink.ts b/src/camera-manager/reolink/engine-reolink.ts index 9a8e498f..ee9c3e55 100644 --- a/src/camera-manager/reolink/engine-reolink.ts +++ b/src/camera-manager/reolink/engine-reolink.ts @@ -18,6 +18,7 @@ import { DeviceRegistryManager } from '../../ha/registry/device'; import { EntityRegistryManager } from '../../ha/registry/entity/types'; import { ResolvedMediaCache } from '../../ha/resolved-media'; import { HomeAssistant } from '../../ha/types'; +import { hasUnsupportedFilters } from '../../query-source.js'; import { allPromises, formatDate, isValidDate } from '../../utils/basic'; import { ViewMedia } from '../../view/item'; import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media'; @@ -255,14 +256,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { query: EventQuery, engineOptions?: EngineOptions, ): Promise { - // Reolink does not support these query types and they will never match. - if ( - query.favorite || - query.tags?.size || - query.what?.size || - query.where?.size || - query.hasSnapshot - ) { + if (hasUnsupportedFilters(query) || query.hasSnapshot) { return null; } diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index f2450737..0a696eaa 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -1,7 +1,8 @@ import { ExpiringEqualityCache } from '../cache/expiring-cache'; import { SSLCiphers } from '../config/schema/cameras'; import { AdvancedCameraCardView } from '../config/schema/common/const'; -import { CapabilityKey, Endpoint, Icon } from '../types'; +import { BaseQuery, QueryFilters, QuerySource } from '../query-source'; +import { CapabilityKey, Endpoint, Icon, Severity } from '../types'; import { ViewMedia } from '../view/item'; // ==== @@ -13,6 +14,7 @@ export enum QueryType { Recording = 'recording-query', RecordingSegments = 'recording-segments-query', MediaMetadata = 'media-metadata', + Review = 'review-query', } export enum QueryResultsType { @@ -20,6 +22,7 @@ export enum QueryResultsType { Recording = 'recording-results', RecordingSegments = 'recording-segments-results', MediaMetadata = 'media-metadata-results', + Review = 'review-results', } export enum Engine { @@ -46,10 +49,12 @@ interface LimitedDataQuery { } export interface MediaQuery - extends CameraQuery, + extends BaseQuery, + CameraQuery, + QueryFilters, Partial, Partial { - favorite?: boolean; + source: QuerySource.Camera; } export interface QueryResults { @@ -74,14 +79,18 @@ export type QueryReturnType = QT extends EventQuery ? RecordingSegmentsQueryResults : QT extends MediaMetadataQuery ? MediaMetadataQueryResults - : never; + : QT extends ReviewQuery + ? ReviewQueryResults + : never; export type PartialQueryConcreteType = PQT extends PartialEventQuery ? EventQuery : PQT extends PartialRecordingQuery ? RecordingQuery : PQT extends PartialRecordingSegmentsQuery ? RecordingSegmentsQuery - : never; + : PQT extends PartialReviewQuery + ? ReviewQuery + : never; export type ResultsMap = Map>; export type EventQueryResultsMap = ResultsMap; @@ -140,16 +149,21 @@ export interface EngineOptions { export interface CameraEvent { cameraID: string; - type: 'new' | 'update' | 'end'; + type: + | 'new' // A new event has started. + | 'update' // An update for an event is available (except GenAI). + | 'end' // An event has ended. + | 'genai'; // An AI based update is available. // When fidelity is `high`, the engine is assumed to provide exact details of // what new media is available. Otherwise all media types are assumed to be // possibly newly available. fidelity?: 'high' | 'low'; - // Whether a new clip/snapshot/recording may be available. + // Whether new media may be available. clip?: boolean; snapshot?: boolean; + review?: boolean; } export type CameraEventCallback = (ev: CameraEvent) => void; @@ -158,6 +172,21 @@ export class CameraManagerRequestCache extends ExpiringEqualityCache< QueryResults > {} +// ==================== +// Default Query Params +// ==================== + +/** + * Default query parameters that engines can provide based on camera configuration. + */ +export interface DefaultQueryParameters { + // Object labels (what was detected) + what?: Set; + + // Zones (where detection occurred) + where?: Set; +} + // =========== // Event Query // =========== @@ -170,15 +199,6 @@ export interface EventQuery extends MediaQuery { // Frigate equivalent: has_clip hasClip?: boolean; - - // Frigate equivalent: label - what?: Set; - - // Frigate equivalent: sub_label - tags?: Set; - - // Frigate equivalent: zone - where?: Set; } export type PartialEventQuery = Partial; @@ -225,3 +245,20 @@ export interface MediaMetadataQueryResults extends QueryResults { type: QueryResultsType.MediaMetadata; metadata: MediaMetadata; } + +// ============ +// Review Query +// ============ + +export interface ReviewQuery extends MediaQuery { + type: QueryType.Review; + + severity?: Severity; +} +export type PartialReviewQuery = Partial; + +export interface ReviewQueryResults extends QueryResults { + type: QueryResultsType.Review; +} + +export type ReviewQueryResultsMap = ResultsMap; diff --git a/src/card-controller/actions/actions-manager.ts b/src/card-controller/actions/actions-manager.ts index 9b38e0df..1ff5795d 100644 --- a/src/card-controller/actions/actions-manager.ts +++ b/src/card-controller/actions/actions-manager.ts @@ -53,7 +53,7 @@ export class ActionsManager implements ActionsExecutor { let specificActions: Actions | undefined = undefined; if (view?.is('live')) { specificActions = config?.live.actions; - } else if (view?.isMediaGalleryView()) { + } else if (view?.isGalleryView()) { specificActions = config?.media_gallery?.actions; } else if (view?.isViewerView()) { specificActions = config?.media_viewer.actions; diff --git a/src/card-controller/actions/actions/base.ts b/src/card-controller/actions/actions/base.ts index d15be46a..e6c4c38b 100644 --- a/src/card-controller/actions/actions/base.ts +++ b/src/card-controller/actions/actions/base.ts @@ -21,26 +21,26 @@ export class BaseAction implements Action { protected _shouldSeekConfirmation(api: CardActionsAPI): boolean { const hass = api.getHASSManager().getHASS(); + const action: ActionConfig = this._action; return ( - (typeof this._action.confirmation === 'boolean' && this._action.confirmation) || - (typeof this._action.confirmation === 'object' && - (!this._action.confirmation.exemptions || - !this._action.confirmation.exemptions.some( - (entry) => entry.user === hass?.user.id, - ))) + (typeof action.confirmation === 'boolean' && action.confirmation) || + (typeof action.confirmation === 'object' && + (!action.confirmation.exemptions || + !action.confirmation.exemptions.some((entry) => entry.user === hass?.user.id))) ); } public async execute(api: CardActionsAPI): Promise { if (this._shouldSeekConfirmation(api)) { - const actionName = isAdvancedCameraCardCustomAction(this._action) - ? this._action.advanced_camera_card_action - : this._action.action; + const action: ActionConfig = this._action; + const baseAction = action.action; + const actionName = isAdvancedCameraCardCustomAction(action) + ? action.advanced_camera_card_action + : baseAction; const text = - (typeof this._action.confirmation === 'object' - ? this._action.confirmation.text - : null) ?? `${localize('actions.confirmation')}: ${actionName}`; + (typeof action.confirmation === 'object' ? action.confirmation.text : null) ?? + `${localize('actions.confirmation')}: ${actionName}`; if (!confirm(text)) { throw new ActionAbortError(localize('actions.abort')); } diff --git a/src/card-controller/actions/actions/info.ts b/src/card-controller/actions/actions/info.ts new file mode 100644 index 00000000..c05089a9 --- /dev/null +++ b/src/card-controller/actions/actions/info.ts @@ -0,0 +1,28 @@ +import { MediaDetailsController } from '../../../components-lib/media/details-controller'; +import { GeneralActionConfig } from '../../../config/schema/actions/custom/general'; +import { ViewItemClassifier } from '../../../view/item-classifier'; +import { CardActionsAPI } from '../../types'; +import { AdvancedCameraCardAction } from './base'; + +export class InfoAction extends AdvancedCameraCardAction { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + + const item = api.getViewManager().getView()?.queryResults?.getSelectedResult(); + if (!ViewItemClassifier.isMedia(item)) { + return; + } + + const controller = new MediaDetailsController(); + controller.calculate(api.getCameraManager(), item); + + api.getOverlayMessageManager().setMessage( + controller.getMessage({ + hass: api.getHASSManager().getHASS() ?? undefined, + viewItemManager: api.getViewItemManager(), + viewManagerEpoch: api.getViewManager().getEpoch(), + capabilities: api.getViewItemManager().getCapabilities(item), + }), + ); + } +} diff --git a/src/card-controller/actions/actions/set-review.ts b/src/card-controller/actions/actions/set-review.ts new file mode 100644 index 00000000..a377215a --- /dev/null +++ b/src/card-controller/actions/actions/set-review.ts @@ -0,0 +1,34 @@ +import { SetReviewActionConfig } from '../../../config/schema/actions/custom/set-review'; +import { ViewItemClassifier } from '../../../view/item-classifier'; +import { CardActionsAPI } from '../../types'; +import { AdvancedCameraCardAction } from './base'; + +export class SetReviewAction extends AdvancedCameraCardAction { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + + const view = api.getViewManager().getView(); + const queryResults = view?.queryResults; + const item = queryResults?.getSelectedResult(); + + if (!ViewItemClassifier.isReview(item) || !queryResults) { + return; + } + + const targetReviewedState = this._action.reviewed ?? !item.isReviewed(); + + await api.getViewItemManager().reviewMedia(item, targetReviewedState); + + // Clone the item to ensure Lit detects the change. + // Test-case: Setting a media item reviewed via the menu, should update the + // reviewed state in a thumbnail. + const clonedItem = item.clone(); + clonedItem.setReviewed(targetReviewedState); + + api.getViewManager().setViewByParameters({ + params: { + queryResults: queryResults.clone().replaceItem(item, clonedItem), + }, + }); + } +} diff --git a/src/card-controller/actions/factory.ts b/src/card-controller/actions/factory.ts index ea0b105f..409ae770 100644 --- a/src/card-controller/actions/factory.ts +++ b/src/card-controller/actions/factory.ts @@ -9,9 +9,10 @@ import { CustomAction } from './actions/custom'; import { DefaultAction } from './actions/default'; import { DisplayModeSelectAction } from './actions/display-mode-select'; import { DownloadAction } from './actions/download'; -import { ExpandAction } from './actions/expand'; import { EffectAction } from './actions/effect'; +import { ExpandAction } from './actions/expand'; import { FullscreenAction } from './actions/fullscreen'; +import { InfoAction } from './actions/info'; import { InternalCallbackAction } from './actions/internal-callback'; import { LogAction } from './actions/log'; import { MediaPlayerAction } from './actions/media-player'; @@ -33,6 +34,7 @@ import { PTZDigitalAction } from './actions/ptz-digital'; import { PTZMultiAction } from './actions/ptz-multi'; import { ReloadAction } from './actions/reload'; import { ScreenshotAction } from './actions/screenshot'; +import { SetReviewAction } from './actions/set-review'; import { SleepAction } from './actions/sleep'; import { StatusBarAction } from './actions/status-bar'; import { SubstreamOffAction } from './actions/substream-off'; @@ -93,6 +95,8 @@ export class ActionFactory { case 'live': case 'recording': case 'recordings': + case 'review': + case 'reviews': case 'snapshot': case 'snapshots': case 'timeline': @@ -110,6 +114,8 @@ export class ActionFactory { return new ExpandAction(context, action, options?.config); case 'fullscreen': return new FullscreenAction(context, action, options?.config); + case 'info': + return new InfoAction(context, action, options?.config); case 'menu_toggle': return new MenuToggleAction(context, action, options?.config); case 'camera_select': @@ -156,6 +162,8 @@ export class ActionFactory { return new StatusBarAction(context, action, options?.config); case 'reload': return new ReloadAction(context, action, options?.config); + case 'set_review': + return new SetReviewAction(context, action, options?.config); case INTERNAL_CALLBACK_ACTION: return new InternalCallbackAction(context, action, options?.config); } diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index e115b870..45c0db1a 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -3,6 +3,8 @@ import { ActionEventTarget } from '../action-handler-directive'; import { isCardInPanel } from '../ha/panel'; import { setOrRemoveAttribute } from '../utils/basic'; import { isBeingCasted } from '../utils/casting'; +import { CardMediaReviewEventTarget } from '../utils/review'; +import { ViewItem } from '../view/item'; import { ActionExecutionRequestEventTarget } from './actions/utils/execution-request'; import { InitializationAspect } from './initialization-manager'; import { CardElementAPI } from './types'; @@ -13,7 +15,8 @@ export type MenuToggleCallback = () => void; export type CardHTMLElement = LitElement & ReactiveControllerHost & ActionEventTarget & - ActionExecutionRequestEventTarget; + ActionExecutionRequestEventTarget & + CardMediaReviewEventTarget; export class CardElementManager { protected _api: CardElementAPI; @@ -120,6 +123,10 @@ export class CardElementManager { 'advanced-camera-card:action:execution-request', this._api.getActionsManager().handleActionExecutionRequestEvent, ); + this._element.addEventListener( + 'advanced-camera-card:media:reviewed', + this._handleMediaReviewed, + ); // Listen for HA `navigate` actions. // See: https://github.com/home-assistant/frontend/blob/273992c8e9c3062c6e49481b6d7d688a07067232/src/common/navigate.ts#L43 @@ -198,6 +205,10 @@ export class CardElementManager { 'advanced-camera-card:action:execution-request', this._api.getActionsManager().handleActionExecutionRequestEvent, ); + this._element.removeEventListener( + 'advanced-camera-card:media:reviewed', + this._handleMediaReviewed, + ); window.removeEventListener( 'location-changed', @@ -208,4 +219,15 @@ export class CardElementManager { this._api.getQueryStringManager().requestExecution, ); } + + protected _handleMediaReviewed = (ev: CustomEvent): void => { + // If the selected media item has a change of review status, update the card + // (e.g. for the menu). + if ( + this._api.getViewManager().getView()?.queryResults?.getSelectedResult() === + ev.detail + ) { + this.update(); + } + }; } diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index d522edb2..15f45156 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -28,9 +28,11 @@ import { InitializationManager } from './initialization-manager'; import { InteractionManager } from './interaction-manager'; import { KeyboardStateManager } from './keyboard-state-manager'; import { MediaLoadedInfoManager } from './media-info-manager'; + import { MediaPlayerManager } from './media-player-manager'; import { MessageManager } from './message-manager'; import { MicrophoneManager } from './microphone-manager'; +import { OverlayMessageManager } from './overlay-message-manager'; import { QueryStringManager } from './query-string-manager'; import { StatusBarItemManager } from './status-bar-item-manager'; import { StyleManager } from './style-manager'; @@ -56,6 +58,7 @@ import { CardMediaPlayerAPI, CardMessageAPI, CardMicrophoneAPI, + CardOverlayMessageAPI, CardQueryStringAPI, CardStyleAPI, CardTriggersAPI, @@ -87,6 +90,7 @@ export class CardController CardMediaPlayerAPI, CardMessageAPI, CardMicrophoneAPI, + CardOverlayMessageAPI, CardQueryStringAPI, CardStyleAPI, CardTriggersAPI, @@ -118,9 +122,11 @@ export class CardController protected _interactionManager = new InteractionManager(this); protected _keyboardStateManager = new KeyboardStateManager(this); protected _mediaLoadedInfoManager = new MediaLoadedInfoManager(this); + protected _mediaPlayerManager = new MediaPlayerManager(this); protected _messageManager = new MessageManager(this); protected _microphoneManager = new MicrophoneManager(this); + protected _overlayMessageManager = new OverlayMessageManager(this); protected _queryStringManager = new QueryStringManager(this); protected _statusBarItemManager = new StatusBarItemManager(this); protected _styleManager = new StyleManager(this); @@ -248,6 +254,10 @@ export class CardController this._microphoneManager = new MicrophoneManager(this); } + public getOverlayMessageManager(): OverlayMessageManager { + return this._overlayMessageManager; + } + public getQueryStringManager(): QueryStringManager { return this._queryStringManager; } diff --git a/src/card-controller/folders/executor.ts b/src/card-controller/folders/executor.ts index a5ba346d..e8e9022d 100644 --- a/src/card-controller/folders/executor.ts +++ b/src/card-controller/folders/executor.ts @@ -15,10 +15,8 @@ export class FoldersExecutor { this._ha = engines?.ha ?? new HAFoldersEngine(); } - public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null { - return ( - this._getFolderEngine(folder.type)?.generateDefaultFolderQuery(folder) ?? null - ); + public getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null { + return this._getFolderEngine(folder.type)?.getDefaultQueryParameters(folder) ?? null; } public generateChildFolderQuery( @@ -79,6 +77,15 @@ export class FoldersExecutor { ); } + public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean { + return ( + this._getFolderEngine(query.folder.type)?.areResultsFresh( + resultsTimestamp, + query, + ) ?? true + ); + } + private _getFolderEngine(type?: FolderType): FoldersEngine | null { switch (type) { case folderTypeSchema.enum.ha: diff --git a/src/card-controller/folders/ha/engine.ts b/src/card-controller/folders/ha/engine.ts index 500c6381..268977cd 100644 --- a/src/card-controller/folders/ha/engine.ts +++ b/src/card-controller/folders/ha/engine.ts @@ -1,16 +1,17 @@ +import { sub } from 'date-fns'; import { NonEmptyTuple } from 'type-fest'; import { ConditionState } from '../../../conditions/types'; import { FolderConfig, folderTypeSchema, HA_MEDIA_SOURCE_ROOT, - HAFolderPathComponent, HAFolderConfig, + HAFolderPathComponent, } from '../../../config/schema/folders'; import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media'; import { BrowseMediaViewFolder } from '../../../ha/browse-media/item'; import { - BrowseMedia, + BROWSE_MEDIA_CACHE_SECONDS, BrowseMediaCache, BrowseMediaMetadata, RichBrowseMedia, @@ -22,16 +23,18 @@ import { } from '../../../ha/browse-media/walker'; import { getMediaDownloadPath } from '../../../ha/download'; import { HomeAssistant } from '../../../ha/types'; +import { QuerySource } from '../../../query-source.js'; import { Endpoint } from '../../../types'; + import { ViewFolder, ViewItem } from '../../../view/item'; import { ViewItemClassifier } from '../../../view/item-classifier'; import { ViewItemCapabilities } from '../../../view/types'; import { DownloadHelpers, EngineOptions, + FolderPathComponent, FolderQuery, FoldersEngine, - FolderPathComponent, } from '../types'; import { MediaMatcher } from './media-matcher'; import { MetadataGenerator } from './metadata-generator.js'; @@ -83,17 +86,18 @@ export class HAFoldersEngine implements FoldersEngine { return; } - public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null { + public getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null { if (folder.type !== folderTypeSchema.enum.ha) { return null; } return { + source: QuerySource.Folder, folder, - path: this.getDefaultFolderPathComponents(folder.ha), + path: this._getDefaultPathComponents(folder.ha), }; } - private getDefaultFolderPathComponents( + private _getDefaultPathComponents( haFolderConfig?: HAFolderConfig, ): NonEmptyTuple { const shouldAddDefaultRoot = @@ -158,14 +162,21 @@ export class HAFoldersEngine implements FoldersEngine { targets: BrowseMediaTarget[], ): BrowseMediaStep[] => { const nextComponent = pathComponents.shift(); + const limit = query.limit ?? null; + return [ { targets, - metadataGenerator: (media: BrowseMedia, parent?: BrowseMedia) => + metadataGenerator: (media, parent) => this._metadataGenerator.generate(media, parent, nextComponent?.ha?.parsers), + // At the final step (no nextComponent), apply limit via earlyExit. + ...(limit && { + earlyExit: (media) => media.length >= limit, + }), + ...(nextComponent && { - matcher: (media: RichBrowseMedia) => + matcher: (media) => this._mediaMatcher.match(hass, media, { matchers: nextComponent.ha?.matchers, // Set foldersOnly to true if there are more stages in the path, @@ -187,9 +198,11 @@ export class HAFoldersEngine implements FoldersEngine { }, ); - return getViewItemsFromBrowseMediaArray(browseMedia, { + const results = getViewItemsFromBrowseMediaArray(browseMedia, { folder: query.folder, + path: query.path, }); + return query.limit ? results.slice(0, query.limit) : results; } public generateChildFolderQuery( @@ -202,7 +215,7 @@ export class HAFoldersEngine implements FoldersEngine { } // Get the full configured path to find parsers/matchers for this depth. - const fullPath = this.getDefaultFolderPathComponents(query.folder.ha); + const fullPath = this._getDefaultPathComponents(query.folder.ha); const nextConfiguredComponent = fullPath[query.path.length]; // Use the configured component's parsers/matchers if available, otherwise @@ -214,4 +227,11 @@ export class HAFoldersEngine implements FoldersEngine { path: [...query.path, { folder, ha }], }; } + + public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean { + return ( + !!query && + resultsTimestamp >= sub(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }) + ); + } } diff --git a/src/card-controller/folders/manager.ts b/src/card-controller/folders/manager.ts index 7d1beb64..1184f8bc 100644 --- a/src/card-controller/folders/manager.ts +++ b/src/card-controller/folders/manager.ts @@ -2,7 +2,9 @@ import { cloneDeep } from 'lodash-es'; import { ConditionState } from '../../conditions/types'; import { FolderConfig, FolderConfigWithoutID } from '../../config/schema/folders'; import { localize } from '../../localize/localize'; +import { hasUnsupportedFilters } from '../../query-source.js'; import { Endpoint } from '../../types'; +import { getFolderID } from '../../utils/folder'; import { ViewFolder, ViewItem } from '../../view/item'; import { ViewItemCapabilities } from '../../view/types'; import { CardFoldersAPI } from '../types'; @@ -26,7 +28,7 @@ export class FoldersManager { public addFolders(folders: FolderConfigWithoutID[]): void { for (const folder of folders) { const folderNumber = this._folders.size; - const id = folder.id ?? `folder/${folderNumber.toString()}`; + const id = getFolderID(folder, folderNumber); if (this._folders.has(id)) { throw new FolderInitializationError( localize('error.duplicate_folder_id'), @@ -57,9 +59,9 @@ export class FoldersManager { : this._folders.values().next().value ?? null; } - public generateDefaultFolderQuery(folder?: FolderConfig): FolderQuery | null { + public getDefaultQueryParameters(folder?: FolderConfig): FolderQuery | null { const _folder = folder ?? this.getFolder(); - return _folder ? this._executor.generateDefaultFolderQuery(_folder) : null; + return _folder ? this._executor.getDefaultQueryParameters(_folder) : null; } public generateChildFolderQuery( @@ -74,12 +76,20 @@ export class FoldersManager { conditionState?: ConditionState, engineOptions?: EngineOptions, ): Promise { + if (hasUnsupportedFilters(query)) { + return null; + } + const hass = this._api.getHASSManager().getHASS(); return hass ? this._executor.expandFolder(hass, query, conditionState, engineOptions) : null; } + public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean { + return this._executor.areResultsFresh(resultsTimestamp, query); + } + public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null { return this._executor.getItemCapabilities(item); } diff --git a/src/card-controller/folders/types.ts b/src/card-controller/folders/types.ts index cea087c9..8afb9c6f 100644 --- a/src/card-controller/folders/types.ts +++ b/src/card-controller/folders/types.ts @@ -3,6 +3,7 @@ import { ConditionState } from '../../conditions/types'; import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders'; import { ResolvedMediaCache } from '../../ha/resolved-media'; import { HomeAssistant } from '../../ha/types'; +import { BaseQuery, QueryFilters, QuerySource } from '../../query-source'; import { Endpoint } from '../../types'; import { AdvancedCameraCardError } from '../../types.js'; import { ViewFolder, ViewItem } from '../../view/item'; @@ -30,12 +31,14 @@ export interface FolderPathComponent extends FolderPathComponentMetadata { folder?: ViewFolder; } -export interface FolderQuery { +export interface FolderQuery extends BaseQuery, QueryFilters { + source: QuerySource.Folder; folder: FolderConfig; // A trail of paths to navigate back to the "root", with the last path being // the path that this query directly refers to. path: NonEmptyTuple; + limit?: number; } // =============== @@ -47,7 +50,8 @@ export interface DownloadHelpers { } export interface FoldersEngine { - generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null; + getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null; + generateChildFolderQuery(query: FolderQuery, folder: ViewFolder): FolderQuery | null; expandFolder( @@ -63,5 +67,8 @@ export interface FoldersEngine { item: ViewItem, options?: DownloadHelpers, ): Promise; + favorite(hass: HomeAssistant | null, item: ViewItem, favorite: boolean): Promise; + + areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean; } diff --git a/src/card-controller/overlay-message-manager.ts b/src/card-controller/overlay-message-manager.ts new file mode 100644 index 00000000..8a9a5375 --- /dev/null +++ b/src/card-controller/overlay-message-manager.ts @@ -0,0 +1,31 @@ +import { OverlayMessage } from '../types'; +import { CardOverlayMessageAPI } from './types'; + +export class OverlayMessageManager { + protected _message: OverlayMessage | null = null; + protected _api: CardOverlayMessageAPI; + + constructor(api: CardOverlayMessageAPI) { + this._api = api; + } + + public getMessage(): OverlayMessage | null { + return this._message; + } + + public hasMessage(): boolean { + return this._message !== null; + } + + public setMessage(message: OverlayMessage): void { + this._message = message; + this._api.getCardElementManager().update(); + } + + public reset(): void { + if (this._message) { + this._message = null; + this._api.getCardElementManager().update(); + } + } +} diff --git a/src/card-controller/status-bar-item-manager.ts b/src/card-controller/status-bar-item-manager.ts index 8fcb9e64..d27aa4ca 100644 --- a/src/card-controller/status-bar-item-manager.ts +++ b/src/card-controller/status-bar-item-manager.ts @@ -47,10 +47,12 @@ export class StatusBarItemManager { ? options?.cameraManager?.getCameraMetadata(options?.view?.camera) : null; const engineIcon = cameraMetadata?.engineIcon ?? null; + const selectedResult = options?.view?.queryResults?.getSelectedResult(); + const severity = selectedResult?.getSeverity() ?? null; const title = options?.view?.is('live') ? cameraMetadata?.title ?? null : options?.view?.isViewerView() - ? options?.view.queryResults?.getSelectedResult()?.getTitle() ?? null + ? selectedResult?.getTitle() ?? null : null; const resolution = options?.mediaLoadedInfo ? this._calculateResolution(options?.mediaLoadedInfo) @@ -60,6 +62,17 @@ export class StatusBarItemManager { : null; return [ + ...(severity + ? [ + { + type: 'custom:advanced-camera-card-status-bar-icon' as const, + icon: 'mdi:circle-medium', + severity, + ...options?.statusConfig?.items.severity, + }, + ] + : []), + ...(title ? [ { diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index 48cdfc8d..7051c31f 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -104,14 +104,16 @@ export class TriggersManager { .actions.trigger; const defaultView = this._api.getConfigManager().getConfig()?.view.default; - // If this is a high-fidelity event where we are certain about new media, - // don't take action unless it's to change to live (Frigate engine may pump - // out events where there's no new media to show). Other trigger actions - // (e.g. media, update) do not make sense without having some new media. + // Early exit guard: If this is a high-fidelity event where we are certain + // about new media, don't take action unless it's to change to live (Frigate + // engine may pump out events where there's no new media to show). Other + // trigger actions (e.g. media, update) do not make sense without having + // some new media. if ( ev.fidelity === 'high' && !ev.snapshot && !ev.clip && + !ev.review && !( triggerAction === 'live' || (triggerAction === 'default' && defaultView === 'live') @@ -139,12 +141,26 @@ export class TriggersManager { }, }); } else if (ev.fidelity === 'high' && triggerAction === 'media') { - await this._api.getViewManager().setViewByParametersWithNewQuery({ - params: { - view: ev.clip ? 'clip' : 'snapshot', - camera: ev.cameraID, - }, - }); + // Choose the most appropriate media view based on what's available. + // Priority: review > clip > snapshot + const view = ev.review + ? 'review' + : ev.clip + ? 'clip' + : ev.snapshot + ? 'snapshot' + : /* istanbul ignore next: unreachable due to early exit guard above -- @preserve */ + null; + + /* istanbul ignore next: unreachable due to early exit guard above -- @preserve */ + if (view) { + await this._api.getViewManager().setViewByParametersWithNewQuery({ + params: { + view, + camera: ev.cameraID, + }, + }); + } } } diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 75f0cfda..08eb9452 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -22,6 +22,7 @@ import type { MediaLoadedInfoManager } from './media-info-manager'; import type { MediaPlayerManager } from './media-player-manager'; import type { MessageManager } from './message-manager'; import type { MicrophoneManager } from './microphone-manager'; +import type { OverlayMessageManager } from './overlay-message-manager'; import type { QueryStringManager } from './query-string-manager'; import type { StatusBarItemManager } from './status-bar-item-manager'; import type { StyleManager } from './style-manager'; @@ -52,6 +53,7 @@ export interface CardActionsAPI { getMediaPlayerManager(): MediaPlayerManager; getMessageManager(): MessageManager; getMicrophoneManager(): MicrophoneManager; + getOverlayMessageManager(): OverlayMessageManager; getStatusBarItemManager(): StatusBarItemManager; getTriggersManager(): TriggersManager; getViewItemManager(): ViewItemManager; @@ -149,6 +151,7 @@ export interface CardElementAPI { getMediaPlayerManager(): MediaPlayerManager; getMicrophoneManager(): MicrophoneManager; getQueryStringManager(): QueryStringManager; + getViewManager(): ViewManager; } export interface CardExpandAPI { @@ -245,6 +248,10 @@ export interface CardMessageAPI { getMediaLoadedInfoManager(): MediaLoadedInfoManager; } +export interface CardOverlayMessageAPI { + getCardElementManager(): CardElementManager; +} + export interface CardMicrophoneAPI { getCardElementManager(): CardElementManager; getConditionStateManager(): ConditionStateManager; diff --git a/src/card-controller/view/item-manager.ts b/src/card-controller/view/item-manager.ts index dc4c720b..275b0506 100644 --- a/src/card-controller/view/item-manager.ts +++ b/src/card-controller/view/item-manager.ts @@ -54,6 +54,12 @@ export class ViewItemManager { } } + public async reviewMedia(item: ViewItem, reviewed: boolean): Promise { + if (ViewItemClassifier.isReview(item)) { + return await this._api.getCameraManager().reviewMedia(item, reviewed); + } + } + private _getMediaSource(item: ViewItem): ViewMediaSource | null { if (ViewItemClassifier.isMedia(item) && item.getCameraID()) { return ViewMediaSource.Camera; diff --git a/src/card-controller/view/modifiers/set-query.ts b/src/card-controller/view/modifiers/set-query.ts index 95c617e1..c472c1b0 100644 --- a/src/card-controller/view/modifiers/set-query.ts +++ b/src/card-controller/view/modifiers/set-query.ts @@ -1,13 +1,16 @@ -import { Query } from '../../../view/query'; import { QueryResults } from '../../../view/query-results'; +import { UnifiedQuery } from '../../../view/unified-query'; import { View } from '../../../view/view'; import { ViewModifier } from '../types'; export class SetQueryViewModifier implements ViewModifier { - protected _query?: Query | null; + protected _query?: UnifiedQuery | null; protected _queryResults?: QueryResults | null; - constructor(options?: { query?: Query | null; queryResults?: QueryResults | null }) { + constructor(options?: { + query?: UnifiedQuery | null; + queryResults?: QueryResults | null; + }) { this._query = options?.query; this._queryResults = options?.queryResults; } diff --git a/src/card-controller/view/query-executor.ts b/src/card-controller/view/query-executor.ts deleted file mode 100644 index c9fb61a1..00000000 --- a/src/card-controller/view/query-executor.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { CapabilitySearchKeys, MediaQuery } from '../../camera-manager/types'; -import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const'; -import { ClipsOrSnapshotsOrAll } from '../../types'; -import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index'; -import { ViewItem } from '../../view/item'; -import { - EventMediaQuery, - FolderViewQuery, - MediaQueries, - Query, - RecordingMediaQuery, -} from '../../view/query'; -import { QueryClassifier } from '../../view/query-classifier'; -import { QueryResults } from '../../view/query-results'; -import { CardViewAPI } from '../types'; -import { QueryExecutorOptions, QueryExecutorResult } from './types'; - -export class QueryExecutor { - protected _api: CardViewAPI; - - constructor(api: CardViewAPI) { - this._api = api; - } - - public async executeDefaultEventQuery(options?: { - cameraID?: string; - eventsMediaType?: ClipsOrSnapshotsOrAll; - executorOptions?: QueryExecutorOptions; - }): Promise { - const capabilitySearch: CapabilitySearchKeys = - !options?.eventsMediaType || options?.eventsMediaType === 'all' - ? { - anyCapabilities: ['clips', 'snapshots'], - } - : options.eventsMediaType; - - const cameraManager = this._api.getCameraManager(); - const cameraIDs = options?.cameraID - ? cameraManager - .getStore() - .getAllDependentCameras(options.cameraID, capabilitySearch) - : cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch); - if (!cameraIDs.size) { - return null; - } - - const rawQueries = cameraManager.generateDefaultEventQueries(cameraIDs, { - limit: this._getChunkLimit(), - ...(options?.eventsMediaType === 'clips' && { hasClip: true }), - ...(options?.eventsMediaType === 'snapshots' && { hasSnapshot: true }), - }); - if (!rawQueries) { - return null; - } - const queries = new EventMediaQuery(rawQueries); - return await this.executeMediaQuery(queries, options?.executorOptions); - } - - public async executeDefaultRecordingQuery(options?: { - cameraID?: string; - executorOptions?: QueryExecutorOptions; - }): Promise { - const cameraManager = this._api.getCameraManager(); - const cameraIDs = options?.cameraID - ? cameraManager.getStore().getAllDependentCameras(options.cameraID, 'recordings') - : cameraManager.getStore().getCameraIDsWithCapability('recordings'); - if (!cameraIDs.size) { - return null; - } - - const rawQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, { - limit: this._getChunkLimit(), - }); - if (!rawQueries) { - return null; - } - const queries = new RecordingMediaQuery(rawQueries); - return await this.executeMediaQuery(queries, options?.executorOptions); - } - - public async executeQuery( - query: Query, - executorOptions?: QueryExecutorOptions, - ): Promise { - /* istanbul ignore else: this path cannot be reached -- @preserve */ - if (QueryClassifier.isMediaQuery(query)) { - return await this.executeMediaQuery(query, executorOptions); - } else if (QueryClassifier.isFolderQuery(query)) { - return await this._executeFolderQuery(query, executorOptions); - } - - /* istanbul ignore next: this path cannot be reached -- @preserve */ - return null; - } - - public async executeMediaQuery( - query: MediaQueries, - executorOptions?: QueryExecutorOptions, - ): Promise { - const queries = query.getQuery(); - if (!queries) { - return null; - } - - const mediaArray = await this._api - .getCameraManager() - .executeMediaQueries(queries, { - useCache: executorOptions?.useCache, - }); - const queryResults = mediaArray - ? this._generateQueriesResults(mediaArray, executorOptions) - : null; - return queryResults ? { query, queryResults } : null; - } - - private _generateQueriesResults( - itemArray: ViewItem[], - executorOptions?: QueryExecutorOptions, - ): QueryResults | null { - const queryResults = new QueryResults({ results: itemArray }); - if (executorOptions?.rejectResults?.(queryResults)) { - return null; - } - - if (executorOptions?.selectResult?.id) { - queryResults.selectBestResult((media) => - media.findIndex((m) => m.getID() === executorOptions.selectResult?.id), - ); - } else if (executorOptions?.selectResult?.func) { - queryResults.selectResultIfFound(executorOptions.selectResult.func); - } else if (executorOptions?.selectResult?.time) { - queryResults.selectBestResult((itemArray) => - findBestMediaTimeIndex( - itemArray, - executorOptions.selectResult?.time?.time as Date, - executorOptions.selectResult?.time?.favorCameraID, - ), - ); - } - return queryResults; - } - - public async executeFolderQuery( - executorOptions?: QueryExecutorOptions, - ): Promise { - const folder = this._api.getFoldersManager().getFolder(executorOptions?.folder); - if (!folder) { - return null; - } - - const query = this._api.getFoldersManager().generateDefaultFolderQuery(folder); - if (!query) { - return null; - } - - return this._executeFolderQuery(new FolderViewQuery(query), executorOptions); - } - - private async _executeFolderQuery( - query: FolderViewQuery, - executorOptions?: QueryExecutorOptions, - ): Promise { - const rawQuery = query.getQuery(); - if (!rawQuery) { - return null; - } - const itemArray = await this._api - .getFoldersManager() - .expandFolder(rawQuery, this._api.getConditionStateManager().getState(), { - useCache: executorOptions?.useCache, - }); - - const queryResults = itemArray - ? this._generateQueriesResults(itemArray, executorOptions) - : null; - return queryResults ? { query, queryResults } : null; - } - - protected _getChunkLimit(): number { - const cardWideConfig = this._api.getConfigManager().getCardWideConfig(); - return ( - cardWideConfig?.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT - ); - } -} diff --git a/src/card-controller/view/types.ts b/src/card-controller/view/types.ts index 58fbae0f..0f59e8fb 100644 --- a/src/card-controller/view/types.ts +++ b/src/card-controller/view/types.ts @@ -2,7 +2,7 @@ import { ViewContext } from 'view'; import { AdvancedCameraCardError } from '../../types.js'; import { ViewItem } from '../../view/item.js'; import { QueryResults } from '../../view/query-results.js'; -import { Query } from '../../view/query.js'; + import { View, ViewParameters } from '../../view/view.js'; export interface ViewModifier { @@ -26,11 +26,6 @@ export interface QueryExecutorOptions { useCache?: boolean; } -export interface QueryExecutorResult { - query: Query; - queryResults: QueryResults; -} - export interface ViewFactoryOptions { // An existing view to evolve from. baseView?: View | null; diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts index be56304e..73b734c9 100644 --- a/src/card-controller/view/view-manager.ts +++ b/src/card-controller/view/view-manager.ts @@ -1,7 +1,6 @@ import { ViewContext } from 'view'; import { log } from '../../utils/debug'; import { getStreamCameraID } from '../../utils/substream'; -import { QueryClassifier } from '../../view/query-classifier'; import { View } from '../../view/view'; import { InitializationAspect } from '../initialization-manager'; import { CardViewAPI } from '../types'; @@ -234,19 +233,19 @@ export class ViewManager implements ViewManagerInterface { // If the user is currently using the viewer, and then switches to the // gallery we make an attempt to keep the query/queryResults the same so // the gallery can be used to click back and forth to the viewer, and the - // selected media can be centered in the gallery. See the matching code in - // `updated()` in `gallery.ts`. We specifically must ensure that the new - // target media of the gallery (e.g. clips, snapshots or recordings) is - // equal to the queries that are currently used in the viewer. + // selected media can be centered in the gallery. // // See: https://github.com/dermotduffy/advanced-camera-card/issues/885 - const switchingFromViewerToGallery = - this._view?.isViewerView() && newView?.isMediaGalleryView(); - const newMediaType = newView?.getDefaultMediaType(); - const alreadyHasMatchingQuery = - QueryClassifier.getMediaType(this._view?.query) === newMediaType; - return !!switchingFromViewerToGallery && alreadyHasMatchingQuery; + if (!this._view?.isViewerView() || !newView?.isGalleryView()) { + return false; + } + + // Check if the viewer came from this gallery type. If they did, we preserve + // the query/results to ensure consistency in navigation between media & + // gallery. + const originView = this._view?.context?.gallery?.originView; + return originView === newView.view; } public setViewWithMergedContext(context: ViewContext | null): void { diff --git a/src/card-controller/view/view-query-executor.ts b/src/card-controller/view/view-query-executor.ts index b929324f..28760cd3 100644 --- a/src/card-controller/view/view-query-executor.ts +++ b/src/card-controller/view/view-query-executor.ts @@ -1,11 +1,15 @@ import { sub } from 'date-fns'; -import { ClipsOrSnapshotsOrAll } from '../../types'; +import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const'; +import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index'; +import { QueryResults } from '../../view/query-results'; +import { UnifiedQuery } from '../../view/unified-query'; +import { MediaTypeSpec, UnifiedQueryBuilder } from '../../view/unified-query-builder'; +import { UnifiedQueryRunner } from '../../view/unified-query-runner'; import { View } from '../../view/view'; import { CardViewAPI } from '../types'; import { MergeContextViewModifier } from './modifiers/merge-context'; import { RemoveContextPropertyViewModifier } from './modifiers/remove-context-property'; import { SetQueryViewModifier } from './modifiers/set-query'; -import { QueryExecutor } from './query-executor'; import { QueryExecutorOptions, ViewModifier } from './types'; /** @@ -14,27 +18,42 @@ import { QueryExecutorOptions, ViewModifier } from './types'; * and if a query is made as part of this view the result can be applied later. */ export class ViewQueryExecutor { - protected _api: CardViewAPI; - protected _executor: QueryExecutor; + private _api: CardViewAPI; - constructor(api: CardViewAPI, executor?: QueryExecutor) { + constructor(api: CardViewAPI) { this._api = api; - this._executor = executor ?? new QueryExecutor(api); } public async getExistingQueryModifiers( view: View, queryExecutorOptions?: QueryExecutorOptions, ): Promise { - return view.query + if (!view.query) { + return null; + } + + const runner = new UnifiedQueryRunner( + this._api.getCameraManager(), + this._api.getFoldersManager(), + this._api.getConditionStateManager(), + ); + + const items = await runner.execute(view.query, { + useCache: queryExecutorOptions?.useCache, + }); + + const queryResults = this._applyResultSelection( + new QueryResults({ results: items }), + queryExecutorOptions, + ); + + return queryResults ? [ new SetQueryViewModifier({ - queryResults: ( - await this._executor.executeQuery(view.query, queryExecutorOptions) - )?.queryResults, + queryResults, }), ] - : []; + : null; } public async getNewQueryModifiers( @@ -47,7 +66,7 @@ export class ViewQueryExecutor { }); } - protected async _executeNewQuery( + private async _executeNewQuery( view: View, queryExecutorOptions?: QueryExecutorOptions, ): Promise { @@ -56,71 +75,111 @@ export class ViewQueryExecutor { return null; } - const mediaType = view?.getDefaultMediaType(); const viewModifiers: ViewModifier[] = []; + const builder = new UnifiedQueryBuilder( + this._api.getCameraManager(), + this._api.getFoldersManager(), + ); + const runner = new UnifiedQueryRunner( + this._api.getCameraManager(), + this._api.getFoldersManager(), + this._api.getConditionStateManager(), + ); - const executeMediaQuery = async ( - mediaType: ClipsOrSnapshotsOrAll | 'recordings' | null, - ): Promise => { - /* istanbul ignore if: this path cannot be reached -- @preserve */ - if (!mediaType) { + const executeQuery = async (query: UnifiedQuery | null): Promise => { + if (!query) { return []; } - const results = - mediaType === 'recordings' - ? await this._executor.executeDefaultRecordingQuery({ - ...(!view.isGrid() && { cameraID: view.camera }), - executorOptions: queryExecutorOptions, - }) - : mediaType === 'clips' || mediaType === 'snapshots' || mediaType === 'all' - ? await this._executor.executeDefaultEventQuery({ - ...(!view.isGrid() && { cameraID: view.camera }), - eventsMediaType: mediaType, - executorOptions: queryExecutorOptions, - }) - : /* istanbul ignore next -- @preserve */ - null; + const items = await runner.execute(query, { + useCache: queryExecutorOptions?.useCache, + }); - return results ? [new SetQueryViewModifier(results)] : []; + return [ + new SetQueryViewModifier({ + query, + queryResults: new QueryResults({ results: items }), + }), + ]; }; - const executeFolderQuery = async (): Promise => { - const results = await this._executor.executeFolderQuery(queryExecutorOptions); - return results ? [new SetQueryViewModifier(results)] : []; - }; + const cameraForQuery = view.isGrid() ? undefined : view.camera; switch (view.view) { case 'live': if (config.live.controls.thumbnails.mode !== 'none') { - viewModifiers.push( - ...(await executeMediaQuery( - config.live.controls.thumbnails.media_type === 'recordings' - ? 'recordings' - : config.live.controls.thumbnails.events_media_type, - )), - ); + const defaultQuery = builder.buildDefaultCameraQuery(cameraForQuery); + if (defaultQuery) { + viewModifiers.push(...(await executeQuery(defaultQuery))); + } } break; - - case 'media': - // If the user is looking at media in the `media` view and then - // changes camera (via the menu) it should default to showing clips - // for the new camera. - viewModifiers.push(...(await executeMediaQuery('clips'))); + case 'timeline': + // Timeline view always queries all cameras with media capabilities. + viewModifiers.push(...(await executeQuery(builder.buildDefaultCameraQuery()))); break; - + case 'media': + // If the user is looking at media in the `media` view and then + // changes camera (via the menu) it should default to showing clips + // for the new camera. case 'clip': case 'clips': + viewModifiers.push( + ...(await executeQuery( + builder.buildCameraMediaQuery(MediaTypeSpec.clips(), { + cameraID: cameraForQuery, + limit: this._getLimit(), + }), + )), + ); + + break; + case 'snapshot': case 'snapshots': + viewModifiers.push( + ...(await executeQuery( + builder.buildCameraMediaQuery(MediaTypeSpec.snapshots(), { + cameraID: cameraForQuery, + limit: this._getLimit(), + }), + )), + ); + break; + case 'recording': case 'recordings': - viewModifiers.push(...(await executeMediaQuery(mediaType))); + viewModifiers.push( + ...(await executeQuery( + builder.buildCameraMediaQuery(MediaTypeSpec.recordings(), { + cameraID: cameraForQuery, + limit: this._getLimit(), + }), + )), + ); break; + + case 'review': + case 'reviews': + viewModifiers.push( + ...(await executeQuery( + builder.buildCameraMediaQuery(MediaTypeSpec.reviews(), { + cameraID: cameraForQuery, + limit: this._getLimit(), + }), + )), + ); + break; + case 'folder': case 'folders': - viewModifiers.push(...(await executeFolderQuery())); + viewModifiers.push( + ...(await executeQuery( + builder.buildDefaultFolderQuery(queryExecutorOptions?.folder, { + limit: this._getLimit(), + }), + )), + ); break; } @@ -131,7 +190,7 @@ export class ViewQueryExecutor { return viewModifiers; } - protected _getTimelineWindowViewModifier(view: View): ViewModifier[] { + private _getTimelineWindowViewModifier(view: View): ViewModifier[] { if (view.is('live')) { // For live views, always force the timeline to now, regardless of // presence or not of events. @@ -166,7 +225,7 @@ export class ViewQueryExecutor { } } - protected _getSeekTimeModifier(time?: Date): ViewModifier[] { + private _getSeekTimeModifier(time?: Date): ViewModifier[] { if (time) { return [ new MergeContextViewModifier({ @@ -179,4 +238,39 @@ export class ViewQueryExecutor { return [new RemoveContextPropertyViewModifier('mediaViewer', 'seek')]; } } + + private _applyResultSelection( + queryResults: QueryResults, + options?: QueryExecutorOptions, + ): QueryResults | null { + if (options?.rejectResults?.(queryResults)) { + return null; + } + + const timeSelection = options?.selectResult?.time; + if (options?.selectResult?.id) { + queryResults.selectBestResult((media) => + media.findIndex((m) => m.getID() === options.selectResult?.id), + ); + } else if (options?.selectResult?.func) { + queryResults.selectResultIfFound(options.selectResult.func); + } else if (timeSelection) { + queryResults.selectBestResult((itemArray) => + findBestMediaTimeIndex( + itemArray, + timeSelection.time, + timeSelection.favorCameraID, + ), + ); + } + + return queryResults; + } + + private _getLimit(): number { + return ( + this._api.getConfigManager().getConfig()?.performance?.features + ?.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT + ); + } } diff --git a/src/card.ts b/src/card.ts index dbdc1741..51163d92 100644 --- a/src/card.ts +++ b/src/card.ts @@ -17,6 +17,7 @@ import './components/menu.js'; import { AdvancedCameraCardMenu } from './components/menu.js'; import './components/message.js'; import { renderMessage } from './components/message.js'; +import './components/overlay-message.js'; import './components/overlay.js'; import { AdvancedCameraCardOverlay } from './components/overlay.js'; import './components/status-bar'; @@ -32,7 +33,7 @@ import { REPO_URL } from './const.js'; import { HomeAssistant, LovelaceCardEditor } from './ha/types.js'; import { localize } from './localize/localize.js'; import cardStyle from './scss/card.scss'; -import { MediaLoadedInfo, Message } from './types.js'; +import { MediaLoadedInfo, Message, OverlayMessage } from './types.js'; import { hasAction } from './utils/action.js'; import { getReleaseVersion } from './utils/diagnostics'; @@ -235,9 +236,9 @@ class AdvancedCameraCard extends LitElement { return html` ${position === 'overlay' - ? html`${getContents('overlay')}` + ? html` + ${getContents('overlay')} + ` : html`
${getContents('outerlay')}
`} @@ -360,8 +361,9 @@ class AdvancedCameraCard extends LitElement { style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}" @advanced-camera-card:message=${(ev: CustomEvent) => this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)} - @advanced-camera-card:media:loaded=${(ev: CustomEvent) => - this._controller.getMediaLoadedInfoManager().set(ev.detail)} + @advanced-camera-card:media:loaded=${(ev: CustomEvent) => { + this._controller.getMediaLoadedInfoManager().set(ev.detail); + }} @advanced-camera-card:media:unloaded=${() => this._controller.getMediaLoadedInfoManager().clear()} @advanced-camera-card:media:volumechange=${ @@ -374,6 +376,11 @@ class AdvancedCameraCard extends LitElement { () => this.requestUpdate() /* Refresh play/pause menu button */ } @advanced-camera-card:focus=${() => this.focus()} + @advanced-camera-card:overlay-message:show=${( + ev: CustomEvent, + ) => this._controller.getOverlayMessageManager().setMessage(ev.detail)} + @advanced-camera-card:overlay-message:dismiss=${() => + this._controller.getOverlayMessageManager().reset()} > ${showLoading ? html` ` : ``} + ${this._controller.getOverlayMessageManager().getMessage() + ? html`` + : ''} `, ); } diff --git a/src/components-lib/folder/up-folder.ts b/src/components-lib/folder/up-folder.ts deleted file mode 100644 index e25bf420..00000000 --- a/src/components-lib/folder/up-folder.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ViewManagerEpoch } from '../../card-controller/view/types'; -import { stopEventFromActivatingCardWideActions } from '../../utils/action'; -import { ViewFolder, ViewItem } from '../../view/item'; -import { QueryClassifier } from '../../view/query-classifier'; -import { View } from '../../view/view'; - -export const upFolderClickHandler = ( - _item: ViewItem, - ev: Event, - viewManagerEpoch?: ViewManagerEpoch, -): void => { - stopEventFromActivatingCardWideActions(ev); - - const query = viewManagerEpoch?.manager.getView()?.query; - if (!query || !QueryClassifier.isFolderQuery(query)) { - return; - } - const rawQuery = query?.getQuery(); - if (!rawQuery?.path || rawQuery?.path.length <= 1) { - return; - } - - const path = rawQuery.path.slice(0, -1); - - viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ - params: { - query: query.clone().setQuery({ - folder: rawQuery.folder, - path: [path[0], ...path.slice(1)], - }), - }, - }); -}; - -export const getUpFolderMediaItem = (view?: View | null): ViewFolder | null => { - const query = view?.query; - if (!query || !QueryClassifier.isFolderQuery(query)) { - return null; - } - - const rawQuery = query.getQuery(); - if (!rawQuery?.folder || !rawQuery?.path || rawQuery.path.length <= 1) { - return null; - } - - return new ViewFolder(rawQuery.folder, { - icon: 'mdi:arrow-up-left', - }); -}; diff --git a/src/components-lib/gallery/controller.ts b/src/components-lib/gallery/controller.ts new file mode 100644 index 00000000..ced110f3 --- /dev/null +++ b/src/components-lib/gallery/controller.ts @@ -0,0 +1,132 @@ +import { ViewManagerEpoch } from '../../card-controller/view/types'; +import { AdvancedCameraCardView } from '../../config/schema/common/const'; +import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails'; +import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery'; +import { errorToConsole } from '../../utils/basic'; +import { ViewItem } from '../../view/item'; +import { ViewItemClassifier } from '../../view/item-classifier'; +import { QueryResults } from '../../view/query-results'; +import { UnifiedQuery } from '../../view/unified-query'; +import { UnifiedQueryRunner } from '../../view/unified-query-runner'; +import { View } from '../../view/view'; +import { GalleryColumnCountRoundMethod } from './gallery-core-controller'; + +interface GalleryViewContext { + // The gallery view type the user navigated from (when in viewer). Used to + // determine if query/results should be preserved when returning. + originView?: AdvancedCameraCardView; +} + +declare module 'view' { + interface ViewContext { + gallery?: GalleryViewContext; + } +} + +// The minimum width of a thumbnail with details enabled. +const GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 300; + +// The minimum width of a folder thumbnail with details enabled. +const FOLDER_THUMBNAIL_DETAILS_WIDTH_MIN = 200; + +export class GalleryController { + private _host: HTMLElement; + private _items: ViewItem[] | null = null; + private _foldersOnly = false; + + public constructor(host: HTMLElement) { + this._host = host; + } + + public getItems(): ViewItem[] | null { + return this._items; + } + + /** + * Set items from view query results. + * Media is reversed so newest appears first in the gallery. + */ + public setItemsFromView(newView?: View | null, oldView?: View | null): void { + const newResults = newView?.queryResults?.getResults() ?? null; + if (newResults === null) { + this._items = null; + return; + } + + if (!this._items || oldView?.queryResults?.getResults() !== newResults) { + // Gallery places the most recent media at the top (the query results + // place the most recent media at the end for use in the viewer). + this._items = [...newResults].reverse(); + } + + this._foldersOnly = this._items?.every((item) => ViewItemClassifier.isFolder(item)); + } + + public setThumbnailSize(size?: number): void { + this._host.style.setProperty( + '--advanced-camera-card-thumbnail-size', + `${size ?? THUMBNAIL_WIDTH_DEFAULT}px`, + ); + } + + public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number { + if (!thumbnailConfig) { + return THUMBNAIL_WIDTH_DEFAULT; + } + if (!thumbnailConfig.show_details) { + return thumbnailConfig.size; + } + + // Use smaller width when all items are folders + return this._foldersOnly + ? FOLDER_THUMBNAIL_DETAILS_WIDTH_MIN + : GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN; + } + + public getColumnCountRoundMethod( + thumbnailConfig?: MediaGalleryThumbnailsConfig, + ): GalleryColumnCountRoundMethod { + return thumbnailConfig?.show_details ? 'floor' : 'ceil'; + } + + public async extend( + runner: UnifiedQueryRunner, + viewManagerEpoch: ViewManagerEpoch, + direction: 'earlier' | 'later', + useCache = true, + ): Promise { + const view = viewManagerEpoch.manager.getView(); + if (!view?.query || !view?.queryResults) { + return; + } + + const existingResults = view.queryResults.getResults(); + if (!existingResults) { + return; + } + + let extended: { query: UnifiedQuery; results: ViewItem[] } | null; + try { + extended = await runner.extend(view.query, existingResults, direction, { + useCache, + }); + } catch (e) { + errorToConsole(e as Error); + return; + } + + if (extended) { + viewManagerEpoch.manager.setViewByParameters({ + baseView: view, + params: { + query: extended.query, + queryResults: new QueryResults({ + results: extended.results, + }).selectResultIfFound( + (item) => item === view.queryResults?.getSelectedResult(), + ), + }, + }); + } + } +} diff --git a/src/components-lib/gallery/folder-gallery-controller.ts b/src/components-lib/gallery/folder-gallery-controller.ts deleted file mode 100644 index c0a85248..00000000 --- a/src/components-lib/gallery/folder-gallery-controller.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { FoldersManager } from '../../card-controller/folders/manager'; -import { ViewManagerInterface } from '../../card-controller/view/types'; -import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails'; -import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery'; -import { stopEventFromActivatingCardWideActions } from '../../utils/action'; -import { ViewItem } from '../../view/item'; -import { ViewItemClassifier } from '../../view/item-classifier'; -import { QueryClassifier } from '../../view/query-classifier'; -import { GalleryColumnCountRoundMethod } from './gallery-core-controller'; - -// The minimum width of a (folder) thumbnail with details enabled. This is -// shorter than for regular camera media as this will consist of just a name. -export const FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 200; - -export class FolderGalleryController { - private _host: HTMLElement; - - public constructor(host: HTMLElement) { - this._host = host; - } - - public setThumbnailSize(size?: number): void { - this._host.style.setProperty( - '--advanced-camera-card-thumbnail-size', - `${size ?? THUMBNAIL_WIDTH_DEFAULT}px`, - ); - } - - public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number { - return !thumbnailConfig - ? THUMBNAIL_WIDTH_DEFAULT - : thumbnailConfig.show_details - ? FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN - : thumbnailConfig.size; - } - - public getColumnCountRoundMethod( - thumbnailConfig?: MediaGalleryThumbnailsConfig, - ): GalleryColumnCountRoundMethod { - return thumbnailConfig?.show_details ? 'floor' : 'ceil'; - } - - public itemClickHandler( - viewManager: ViewManagerInterface, - item: ViewItem, - ev: Event, - foldersManager?: FoldersManager, - ): void { - stopEventFromActivatingCardWideActions(ev); - - const view = viewManager.getView(); - if (!view) { - return; - } - if (ViewItemClassifier.isMedia(item)) { - viewManager.setViewByParameters({ - params: { - view: 'media', - queryResults: view.queryResults - ?.clone() - .selectResultIfFound((result) => result === item), - }, - }); - } else if ( - ViewItemClassifier.isFolder(item) && - QueryClassifier.isFolderQuery(view.query) - ) { - const rawQuery = view.query.getQuery(); - if (!rawQuery || !foldersManager) { - return; - } - - const newQuery = foldersManager.generateChildFolderQuery(rawQuery, item); - if (!newQuery) { - return; - } - - viewManager.setViewByParametersWithExistingQuery({ - params: { - query: view.query.clone().setQuery(newQuery), - }, - }); - } - } -} diff --git a/src/components-lib/gallery/media-gallery-controller.ts b/src/components-lib/gallery/media-gallery-controller.ts deleted file mode 100644 index 7a5e0830..00000000 --- a/src/components-lib/gallery/media-gallery-controller.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { CameraManager, ExtendedMediaQueryResult } from '../../camera-manager/manager'; -import { EventQuery, MediaQuery, RecordingQuery } from '../../camera-manager/types'; -import { - ViewManagerEpoch, - ViewManagerInterface, -} from '../../card-controller/view/types'; -import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails'; -import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery'; -import { stopEventFromActivatingCardWideActions } from '../../utils/action'; -import { errorToConsole } from '../../utils/basic'; -import { ViewItem } from '../../view/item'; -import { EventMediaQuery, RecordingMediaQuery } from '../../view/query'; -import { QueryClassifier } from '../../view/query-classifier'; -import { QueryResults } from '../../view/query-results'; -import { View } from '../../view/view'; -import { GalleryColumnCountRoundMethod } from './gallery-core-controller'; - -// The minimum width of a thumbnail with details enabled. -export const MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 300; - -export class MediaGalleryController { - private _host: HTMLElement; - private _media: ViewItem[] | null = null; - - public constructor(host: HTMLElement) { - this._host = host; - } - - public getMedia(): ViewItem[] | null { - return this._media; - } - - public setMediaFromView(newView?: View | null, oldView?: View | null): void { - const newResults = newView?.queryResults?.getResults() ?? null; - if (newResults === null) { - this._media = null; - return; - } - - if (!this._media || oldView?.queryResults?.getResults() !== newResults) { - // Media gallery places the most recent media at the top (the query - // results place the most recent media at the end for use in the viewer). - // This is copied to a new array to avoid reversing the query results in - // place. - this._media = [...newResults].reverse(); - } - } - - public setThumbnailSize(size?: number): void { - this._host.style.setProperty( - '--advanced-camera-card-thumbnail-size', - `${size ?? THUMBNAIL_WIDTH_DEFAULT}px`, - ); - } - - public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number { - return !thumbnailConfig - ? THUMBNAIL_WIDTH_DEFAULT - : thumbnailConfig.show_details - ? MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN - : thumbnailConfig.size; - } - - public getColumnCountRoundMethod( - thumbnailConfig?: MediaGalleryThumbnailsConfig, - ): GalleryColumnCountRoundMethod { - return thumbnailConfig?.show_details ? 'floor' : 'ceil'; - } - - public async extendMediaGallery( - cameraManager: CameraManager, - viewManagerEpoch: ViewManagerEpoch, - direction: 'earlier' | 'later', - useCache = true, - ): Promise { - const view = viewManagerEpoch.manager.getView(); - if (!view) { - return; - } - - const query = view.query; - const existingMedia = view.queryResults?.getResults(); - if (!existingMedia || !query || !QueryClassifier.isMediaQuery(query)) { - return; - } - - const rawQueries = query.getQuery() ?? null; - if (!rawQueries) { - return; - } - - let extension: ExtendedMediaQueryResult | null; - try { - extension = await cameraManager.extendMediaQueries( - rawQueries, - existingMedia, - direction, - { - useCache: useCache, - }, - ); - } catch (e) { - errorToConsole(e as Error); - return; - } - - if (extension) { - const newMediaQueries = QueryClassifier.isEventQuery(query) - ? new EventMediaQuery(extension.queries as EventQuery[]) - : QueryClassifier.isRecordingQuery(query) - ? new RecordingMediaQuery(extension.queries as RecordingQuery[]) - : /* istanbul ignore next: this path cannot be reached -- @preserve */ - null; - - /* istanbul ignore else: this path cannot be reached, as we explicitly - check for media queries above -- @preserve */ - if (newMediaQueries) { - viewManagerEpoch.manager.setViewByParameters({ - baseView: view, - params: { - query: newMediaQueries, - queryResults: new QueryResults({ - results: extension.results, - }).selectResultIfFound( - (media) => media === view.queryResults?.getSelectedResult(), - ), - }, - }); - } - } - } - - public itemClickHandler( - viewManager: ViewManagerInterface, - reversedIndex: number, - ev: Event, - ): void { - stopEventFromActivatingCardWideActions(ev); - - const view = viewManager.getView(); - if (!view || !this._media?.length) { - return; - } - - viewManager.setViewByParameters({ - params: { - view: 'media', - queryResults: view.queryResults?.clone().selectIndex( - // Media in the gallery is reversed vs the queryResults (see - // note above). - this._media.length - reversedIndex - 1, - ), - }, - }); - } -} diff --git a/src/components-lib/media-filter-controller.ts b/src/components-lib/media-filter-controller.ts index 6d14c8b2..7c92aaa2 100644 --- a/src/components-lib/media-filter-controller.ts +++ b/src/components-lib/media-filter-controller.ts @@ -12,27 +12,28 @@ import { } from 'date-fns'; import { LitElement } from 'lit'; import { isEqual, orderBy, uniqWith } from 'lodash-es'; -import { CameraManager } from '../camera-manager/manager'; +import { CameraManager, CameraQueryClassifier } from '../camera-manager/manager'; import { DateRange, PartialDateRange } from '../camera-manager/range'; -import { CameraQuery, MediaMetadata, QueryType } from '../camera-manager/types'; +import { + EventQuery, + MediaMetadata, + QueryType, + ReviewQuery, +} from '../camera-manager/types'; +import { FoldersManager } from '../card-controller/folders/manager'; import { ViewManagerInterface } from '../card-controller/view/types'; import { SelectOption, SelectValues } from '../components/select'; import { CardWideConfig } from '../config/schema/types'; import { localize } from '../localize/localize'; +import { ViewMediaType } from '../types'; import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic'; -import { EventMediaQuery, RecordingMediaQuery } from '../view/query'; -import { QueryClassifier } from '../view/query-classifier'; - -interface MediaFilterControls { - events: boolean; - recordings: boolean; - favorites: boolean; -} +import { UnifiedQueryBuilder } from '../view/unified-query-builder'; export interface MediaFilterCoreDefaults { cameraIDs?: string[]; favorite?: MediaFilterCoreFavoriteSelection; - mediaType?: MediaFilterMediaType; + reviewed?: MediaFilterCoreReviewedSelection; + mediaTypes?: MediaFilterMediaType[]; what?: string[]; when?: string; where?: string[]; @@ -44,6 +45,11 @@ export enum MediaFilterCoreFavoriteSelection { NotFavorite = 'not-favorite', } +export enum MediaFilterCoreReviewedSelection { + Reviewed = 'reviewed', + NotReviewed = 'not-reviewed', +} + export enum MediaFilterCoreWhen { Today = 'today', Yesterday = 'yesterday', @@ -56,6 +62,7 @@ export enum MediaFilterMediaType { Clips = 'clips', Snapshots = 'snapshots', Recordings = 'recordings', + Reviews = 'reviews', } export class MediaFilterController { @@ -72,6 +79,7 @@ export class MediaFilterController { protected _whereOptions: SelectOption[] = []; protected _tagsOptions: SelectOption[] = []; protected _favoriteOptions: SelectOption[]; + protected _reviewedOptions: SelectOption[]; protected _defaults: MediaFilterCoreDefaults | null = null; protected _viewManager: ViewManagerInterface | null = null; @@ -102,6 +110,20 @@ export class MediaFilterController { value: MediaFilterMediaType.Recordings, label: localize('media_filter.media_types.recordings'), }, + { + value: MediaFilterMediaType.Reviews, + label: localize('media_filter.media_types.reviews'), + }, + ]; + this._reviewedOptions = [ + { + value: MediaFilterCoreReviewedSelection.Reviewed, + label: localize('media_filter.reviewed'), + }, + { + value: MediaFilterCoreReviewedSelection.NotReviewed, + label: localize('media_filter.not_reviewed'), + }, ]; this._staticWhenOptions = [ { @@ -149,6 +171,9 @@ export class MediaFilterController { public getFavoriteOptions(): SelectOption[] { return this._favoriteOptions; } + public getReviewedOptions(): SelectOption[] { + return this._reviewedOptions; + } public getDefaults(): MediaFilterCoreDefaults | null { return this._defaults; } @@ -158,229 +183,204 @@ export class MediaFilterController { public async valueChangeHandler( cameraManager: CameraManager, + foldersManager: FoldersManager, cardWideConfig: CardWideConfig, values: { - camera?: string | string[]; - mediaType?: MediaFilterMediaType; + camera?: SelectValues; + mediaTypes?: SelectValues; when: { - selected?: string | string[]; + selected?: SelectValues; from?: Date | null; to?: Date | null; }; - favorite?: MediaFilterCoreFavoriteSelection; - where?: string | string[]; - what?: string | string[]; - tags?: string | string[]; + favorite?: SelectValues; + reviewed?: SelectValues; + where?: SelectValues; + what?: SelectValues; + tags?: SelectValues; }, // eslint-disable-next-line @typescript-eslint/no-unused-vars _ev?: unknown, ): Promise { - const getArrayValueAsSet = (val?: SelectValues): Set | null => { + const getArrayValueAsSet = (val?: SelectValues): Set | null => { // The reported value may be '' if the field is clearable (i.e. the user // can click 'x'). if (val && Array.isArray(val) && val.length && !val.includes('')) { - return new Set([...val]); + return new Set([...val]) as Set; } return null; }; - const cameraIDs = - getArrayValueAsSet(values.camera) ?? this._getAllCameraIDs(cameraManager); - if (!cameraIDs.size || !values.mediaType) { - return; - } - const when = this._getWhen(values.when); const favorite = values.favorite ? values.favorite === MediaFilterCoreFavoriteSelection.Favorite : null; - - // A note on views: - // - In the below, if the user selects a camera to view media for, the main - // view camera is also set to that value (e.g. a user browsing the - // gallery, chooses a different camera in the media filter, then - // subsequently chooses the live button -- they would expect the live view - // for that filtered camera not the prior camera). - // - Similarly, if the user chooses clips or snapshots, set the actual view - // to 'clips' or 'snapshots' in order to ensure the right icon is shown as - // selected in the menu. + const reviewed = values.reviewed + ? values.reviewed === MediaFilterCoreReviewedSelection.Reviewed + : null; + const where = getArrayValueAsSet(values.where); + const what = getArrayValueAsSet(values.what); + const tags = getArrayValueAsSet(values.tags); const limit = cardWideConfig.performance?.features.media_chunk_size; - if ( - values.mediaType === MediaFilterMediaType.Clips || - values.mediaType === MediaFilterMediaType.Snapshots - ) { - const where = getArrayValueAsSet(values.where); - const what = getArrayValueAsSet(values.what); - const tags = getArrayValueAsSet(values.tags); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildFilterQuery( + getArrayValueAsSet(values.camera), + getArrayValueAsSet(values.mediaTypes), + { + ...(when?.start && { start: when.start }), + ...(when?.end && { end: when.end }), + ...(limit && { limit }), + ...(favorite !== null && { favorite }), + ...(reviewed !== null && { reviewed }), + ...(tags && { tags }), + ...(what && { what }), + ...(where && { where }), + }, + ); - const queries = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: cameraIDs, - ...(tags && { tags: tags }), - ...(what && { what: what }), - ...(where && { where: where }), - ...(favorite !== null && { favorite: favorite }), - ...(when && { - ...(when.start && { start: when.start }), - ...(when.end && { end: when.end }), - }), - ...(limit && { limit: limit }), - ...(values.mediaType === MediaFilterMediaType.Clips && { hasClip: true }), - ...(values.mediaType === MediaFilterMediaType.Snapshots && { - hasSnapshot: true, - }), - }, - ]); - - this._viewManager?.setViewByParametersWithExistingQuery({ - params: { - query: queries, - - // See 'A note on views' above for these two arguments - ...(cameraIDs.size === 1 && { camera: [...cameraIDs][0] }), - view: values.mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots', - }, - }); - } else { - const queries = new RecordingMediaQuery([ - { - type: QueryType.Recording, - cameraIDs: cameraIDs, - ...(limit && { limit: limit }), - ...(when && { - ...(when.start && { start: when.start }), - ...(when.end && { end: when.end }), - }), - ...(favorite !== null && { favorite: favorite }), - }, - ]); - - this._viewManager?.setViewByParametersWithExistingQuery({ - params: { - query: queries, - - // See 'A note on views' above for these two arguments - ...(cameraIDs.size === 1 && { camera: [...cameraIDs][0] }), - view: 'recordings', - }, - }); + if (!query) { + return; } + // Get camera IDs from the query (builder may have defaulted these) + const queryCameraIDs = query.getAllCameraIDs(); + const cameraID = queryCameraIDs.size === 1 ? [...queryCameraIDs][0] : undefined; + + this._viewManager?.setViewByParametersWithExistingQuery({ + params: { + query, + // If single camera, set it as the active camera for menu navigation + ...(cameraID && { camera: cameraID }), + }, + }); + // Need to ensure we update the element as the date-picker selections may // have changed, and we need to un/set the selected class. this._host.requestUpdate(); } - protected _getAllCameraIDs(cameraManager: CameraManager): Set { - return cameraManager.getStore().getCameraIDsWithCapability({ - anyCapabilities: ['clips', 'snapshots', 'recordings'], - }); - } - - public computeInitialDefaultsFromView(cameraManager: CameraManager): void { + public computeInitialDefaultsFromView( + cameraManager: CameraManager, + foldersManager: FoldersManager, + ): void { const view = this._viewManager?.getView(); const query = view?.query; - const allCameraIDs = this._getAllCameraIDs(cameraManager); - if (!view || !QueryClassifier.isMediaQuery(query) || !allCameraIDs.size) { + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const allCameraIDs = builder.getAllMediaCapableCameraIDs(); + if (!view || !query?.hasNodes() || !allCameraIDs.size) { return; } - const queries = query.getQuery(); - if (!queries) { - return; - } - - let mediaType: MediaFilterMediaType | undefined; + const mediaQueries = query.getMediaQueries(); + const mediaTypes: MediaFilterMediaType[] = []; let cameraIDs: string[] | undefined; let what: string[] | undefined; let where: string[] | undefined; let favorite: MediaFilterCoreFavoriteSelection | undefined; + let reviewed: MediaFilterCoreReviewedSelection | undefined; let tags: string[] | undefined; - const cameraIDSets = uniqWith( - queries.map((query: CameraQuery) => query.cameraIDs), - isEqual, - ); - // Special note: If all visible cameras are selected, this is the same as no - // selector at all. - if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, allCameraIDs)) { - cameraIDs = [...queries[0].cameraIDs]; + const cameraIDsFromQuery = query.getAllCameraIDs(); + + // Note: With folder filtering, selecting all cameras is NOT the same as + // selecting none. "All cameras" means just camera media, while "none" + // would include folder media too. + if (cameraIDsFromQuery.size > 0) { + cameraIDs = [...cameraIDsFromQuery]; } - const favoriteValues = uniqWith( - queries.map((query) => query.favorite), - isEqual, + // Extract favorite from all media queries (only if explicitly set to true/false) + const favoriteValues = new Set( + mediaQueries.map((mediaQuery) => + CameraQueryClassifier.isEventQuery(mediaQuery) ? mediaQuery.favorite : undefined, + ), ); - if (favoriteValues.length === 1 && queries[0].favorite !== undefined) { - favorite = queries[0].favorite - ? MediaFilterCoreFavoriteSelection.Favorite - : MediaFilterCoreFavoriteSelection.NotFavorite; + if (favoriteValues.size === 1) { + const fav = [...favoriteValues][0]; + if (fav !== undefined) { + favorite = fav + ? MediaFilterCoreFavoriteSelection.Favorite + : MediaFilterCoreFavoriteSelection.NotFavorite; + } } - /* istanbul ignore else: the else path cannot be reached -- @preserve */ - if (QueryClassifier.isEventQuery(view.query)) { - const queries = view.query.getQuery(); + // Detect media types from queries + const eventQueries = query.getMediaQueries({ type: QueryType.Event }); + if (eventQueries.length > 0) { + const hasClips = eventQueries.some((q) => q.hasClip); + const hasSnapshots = eventQueries.some((q) => q.hasSnapshot); + const hasNeither = !hasClips && !hasSnapshots; - /* istanbul ignore if: the if path cannot be reached -- @preserve */ - if (!queries) { - return; + if (hasClips || hasNeither) { + mediaTypes.push(MediaFilterMediaType.Clips); } - - const hasClips = uniqWith( - queries.map((query) => query.hasClip), - isEqual, - ); - const hasSnapshots = uniqWith( - queries.map((query) => query.hasSnapshot), - isEqual, - ); - if (hasClips.length === 1 && hasSnapshots.length === 1) { - mediaType = !!hasClips[0] - ? MediaFilterMediaType.Clips - : !!hasSnapshots[0] - ? MediaFilterMediaType.Snapshots - : undefined; + if (hasSnapshots || hasNeither) { + mediaTypes.push(MediaFilterMediaType.Snapshots); } + } + if (query.hasMediaQueriesOfType(QueryType.Recording)) { + mediaTypes.push(MediaFilterMediaType.Recordings); + } + if (query.hasMediaQueriesOfType(QueryType.Review)) { + mediaTypes.push(MediaFilterMediaType.Reviews); + } + if (eventQueries.length > 0) { const whatSets = uniqWith( - queries.map((query) => query.what), + eventQueries.map((q) => q.what), isEqual, ); - if (whatSets.length === 1 && queries[0].what?.size) { - what = [...queries[0].what]; + if (whatSets.length === 1 && eventQueries[0].what?.size) { + what = [...eventQueries[0].what]; } const whereSets = uniqWith( - queries.map((query) => query.where), + eventQueries.map((q) => q.where), isEqual, ); - if (whereSets.length === 1 && queries[0].where?.size) { - where = [...queries[0].where]; + if (whereSets.length === 1 && eventQueries[0].where?.size) { + where = [...eventQueries[0].where]; } const tagsSets = uniqWith( - queries.map((query) => query.tags), + eventQueries.map((q) => q.tags), isEqual, ); - if (tagsSets.length === 1 && queries[0].tags?.size) { - tags = [...queries[0].tags]; + if (tagsSets.length === 1 && eventQueries[0].tags?.size) { + tags = [...eventQueries[0].tags]; + } + } + + // Extract reviewed from review queries (only if explicitly set to true/false) + const reviewQueries = query.getMediaQueries({ type: QueryType.Review }); + if (reviewQueries.length > 0) { + const reviewedValues = new Set(reviewQueries.map((q) => q.reviewed)); + if (reviewedValues.size === 1) { + const rev = [...reviewedValues][0]; + if (rev !== undefined) { + reviewed = rev + ? MediaFilterCoreReviewedSelection.Reviewed + : MediaFilterCoreReviewedSelection.NotReviewed; + } } - } else if (QueryClassifier.isRecordingQuery(view.query)) { - mediaType = MediaFilterMediaType.Recordings; } this._defaults = { - ...(mediaType && { mediaType: mediaType }), - ...(cameraIDs && { cameraIDs: cameraIDs }), - ...(what && { what: what }), - ...(where && { where: where }), - ...(favorite !== undefined && { favorite: favorite }), - ...(tags && { tags: tags }), + ...(mediaTypes.length && { mediaTypes }), + ...(cameraIDs && { cameraIDs }), + ...(what && { what }), + ...(where && { where }), + ...(favorite !== undefined && { favorite }), + ...(reviewed !== undefined && { reviewed }), + ...(tags && { tags }), }; } - public computeCameraOptions(cameraManager: CameraManager): void { - this._cameraOptions = [...this._getAllCameraIDs(cameraManager)].map((cameraID) => ({ + public computeCameraOptions( + cameraManager: CameraManager, + foldersManager: FoldersManager, + ): void { + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + this._cameraOptions = [...builder.getAllMediaCapableCameraIDs()].map((cameraID) => ({ value: cameraID, label: cameraManager.getCameraMetadata(cameraID)?.title ?? cameraID, })); @@ -441,23 +441,6 @@ export class MediaFilterController { this._host.requestUpdate(); } - public getControlsToShow(cameraManager: CameraManager): MediaFilterControls { - const view = this._viewManager?.getView(); - const events = QueryClassifier.isEventQuery(view?.query); - const recordings = QueryClassifier.isRecordingQuery(view?.query); - const managerCapabilities = cameraManager.getAggregateCameraCapabilities(); - - return { - events: events, - recordings: recordings, - favorites: events - ? managerCapabilities?.has('favorite-events') - : recordings - ? managerCapabilities?.has('favorite-recordings') - : false, - }; - } - protected _computeWhenOptions(): void { this._whenOptions = [...this._staticWhenOptions, ...this._metaDataWhenOptions]; } diff --git a/src/components-lib/thumbnail/details-controller.ts b/src/components-lib/media/details-controller.ts similarity index 50% rename from src/components-lib/thumbnail/details-controller.ts rename to src/components-lib/media/details-controller.ts index 7e912afb..0955ecd6 100644 --- a/src/components-lib/thumbnail/details-controller.ts +++ b/src/components-lib/media/details-controller.ts @@ -1,27 +1,40 @@ import { format } from 'date-fns'; import { CameraManager } from '../../camera-manager/manager'; import { CameraManagerCameraMetadata } from '../../camera-manager/types'; +import { ViewItemManager } from '../../card-controller/view/item-manager'; +import { ViewManagerEpoch } from '../../card-controller/view/types'; +import { HomeAssistant } from '../../ha/types'; import { localize } from '../../localize/localize'; -import { Icon } from '../../types'; +import { MetadataField, OverlayMessage, OverlayMessageControl } from '../../types'; import { getDurationString, prettifyTitle } from '../../utils/basic'; +import { + downloadMedia, + navigateToTimeline, + toggleFavorite, + toggleReviewed, +} from '../../utils/media-actions'; import { ViewItem } from '../../view/item'; import { ViewItemClassifier } from '../../view/item-classifier'; +import { ViewItemCapabilities } from '../../view/types'; -interface Detail { - icon?: Icon; - hint?: string; - title: string; +export interface OverlayControlsContext { + hass?: HomeAssistant; + viewItemManager?: ViewItemManager; + viewManagerEpoch?: ViewManagerEpoch; + capabilities?: ViewItemCapabilities | null; } -export class ThumbnailDetailsController { - private _details: Detail[] = []; - private _heading: string | null = null; +export class MediaDetailsController { + private _details: MetadataField[] = []; + private _heading: MetadataField | null = null; + private _item: ViewItem | null = null; public calculate( cameraManager?: CameraManager | null, item?: ViewItem, seek?: Date, ): void { + this._item = item ?? null; const cameraID = ViewItemClassifier.isMedia(item) ? item.getCameraID() : null; const cameraMetadata = cameraID ? cameraManager?.getCameraMetadata(cameraID) ?? null @@ -43,12 +56,34 @@ export class ThumbnailDetailsController { const rawScore = item.getScore(); const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null; - this._heading = whatWithTags ? `${whatWithTags}${score ? ` ${score}` : ''}` : null; + this._heading = whatWithTags + ? { title: `${whatWithTags}${score ? ` ${score}` : ''}` } + : null; + return; + } + + if (ViewItemClassifier.isReview(item)) { + const title = item.getTitle(); + const severity = item.getSeverity(); + + this._heading = title + ? { + title: title, + emphasis: severity ?? undefined, + hint: + localize('common.severity') + + ': ' + + localize('common.severities.' + severity), + icon: { icon: 'mdi:circle-medium' }, + } + : null; return; } if (cameraMetadata?.title) { - this._heading = cameraMetadata.title; + this._heading = { + title: cameraMetadata.title, + }; return; } @@ -137,7 +172,9 @@ export class ThumbnailDetailsController { // To avoid duplication, if the event has a starttime, the title is omitted // from the details. - const includeTitle = !ViewItemClassifier.isEvent(item) || !startTime; + const includeTitle = + (!ViewItemClassifier.isEvent(item) && !ViewItemClassifier.isReview(item)) || + !startTime; this._details = [ ...(includeTitle && itemTitle ? [ @@ -154,11 +191,91 @@ export class ThumbnailDetailsController { ]; } - public getHeading(): string | null { + public getHeading(): MetadataField | null { return this._heading; } - public getDetails(): Detail[] { + public getDetails(): MetadataField[] { return this._details; } + + /** + * Get an overlay message for the item. + * @param context Optional context to include controls. + * @returns An OverlayMessage. + */ + public getMessage(context?: OverlayControlsContext): OverlayMessage { + return { + heading: this._heading ?? undefined, + controls: context ? this._getControls(context) : undefined, + details: this._details, + text: ViewItemClassifier.isMedia(this._item) + ? this._item.getDescription() ?? undefined + : undefined, + }; + } + + protected _getControls(context: OverlayControlsContext): OverlayMessageControl[] { + const controls: OverlayMessageControl[] = []; + const item = this._item; + + if (!item) { + return controls; + } + + if (ViewItemClassifier.isReview(item)) { + const isReviewed = item.isReviewed(); + controls.push({ + title: isReviewed + ? localize('common.set_reviews.unreviewed') + : localize('common.set_reviews.reviewed'), + icon: { icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline' }, + callback: async () => { + const success = await toggleReviewed(item, context); + return success ? this.getMessage(context) : null; + }, + }); + } + + if (context.capabilities?.canFavorite && ViewItemClassifier.isMedia(item)) { + const isFavorite = item.isFavorite(); + controls.push({ + title: localize('thumbnail.retain_indefinitely'), + icon: { icon: isFavorite ? 'mdi:star' : 'mdi:star-outline' }, + emphasis: isFavorite ? 'medium' : undefined, + callback: async () => { + const success = await toggleFavorite(item, context); + return success ? this.getMessage(context) : null; + }, + }); + } + + if (context.capabilities?.canDownload && item.getID()) { + controls.push({ + title: localize('thumbnail.download'), + icon: { icon: 'mdi:download' }, + callback: async () => { + await downloadMedia(item, context); + + // Close overlay message after download. + return null; + }, + }); + } + + if (ViewItemClassifier.supportsTimeline(item) && context.viewManagerEpoch) { + controls.push({ + title: localize('thumbnail.timeline'), + icon: { icon: 'mdi:target' }, + callback: () => { + navigateToTimeline(item, context); + + // Close overlay after timeline navigation + return null; + }, + }); + } + + return controls; + } } diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index fca77739..f1f57c6f 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -5,7 +5,11 @@ import { FullscreenManager } from '../card-controller/fullscreen/fullscreen-mana import { MediaPlayerManager } from '../card-controller/media-player-manager'; import { MicrophoneManager } from '../card-controller/microphone-manager'; import { ViewManager } from '../card-controller/view/view-manager'; -import { VIEWS_USER_SPECIFIED } from '../config/schema/common/const'; +import { + AdvancedCameraCardView, + VIEWS_USER_SPECIFIED, +} from '../config/schema/common/const'; +import { MenuItemBase } from '../config/schema/elements/custom/menu/base'; import { MenuItem } from '../config/schema/elements/custom/menu/types'; import { AdvancedCameraCardConfig } from '../config/schema/types'; import { getEntityTitle } from '../ha/get-entity-title'; @@ -19,6 +23,7 @@ import { createMediaPlayerAction, createPTZControlsAction, createPTZMultiAction, + createSetReviewAction, createViewAction, isAdvancedCameraCardCustomAction, } from '../utils/action'; @@ -27,7 +32,6 @@ import { isBeingCasted } from '../utils/casting'; import { getPTZTarget } from '../utils/ptz'; import { getStreamCameraID, hasSubstream } from '../utils/substream'; import { ViewItemClassifier } from '../view/item-classifier'; -import { QueryClassifier } from '../view/query-classifier'; import { View } from '../view/view'; import { getCameraIDsForViewName, isViewSupportedByCamera } from '../view/view-support'; @@ -77,9 +81,12 @@ export class MenuButtonController { this._getClipsButton(config, cameraManager, foldersManager, options?.view), this._getSnapshotsButton(config, cameraManager, foldersManager, options?.view), this._getRecordingsButton(config, cameraManager, foldersManager, options?.view), + this._getReviewsButton(config, cameraManager, foldersManager, options?.view), this._getImageButton(config, cameraManager, foldersManager, options?.view), this._getTimelineButton(config, cameraManager, foldersManager, options?.view), this._getDownloadButton(config, cameraManager, options?.view), + this._getInfoButton(config, cameraManager, options?.view), + this._getSetReviewButton(config, options?.view), this._getCameraUIButton(config, options?.showCameraUIButton), this._getMicrophoneButton( config, @@ -245,17 +252,45 @@ export class MenuButtonController { : null; } + /** + * Determine if a media button (clips/snapshots) should be shown. These are + * hidden by default if reviews are supported. + */ + protected _shouldShowEventMediaButton( + viewName: 'clips' | 'snapshots', + buttonConfig: MenuItemBase, + cameraManager: CameraManager, + foldersManager: FoldersManager, + view?: View | null, + ): boolean { + const supportsEventView = + view && + isViewSupportedByCamera(viewName, cameraManager, foldersManager, view.camera); + const supportsReviewsView = + view && + isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera); + + // Show if: explicitly enabled OR (supports view AND reviews not supported) + return !!supportsEventView && (buttonConfig.enabled || !supportsReviewsView); + } + protected _getClipsButton( config: AdvancedCameraCardConfig, cameraManager: CameraManager, foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return view && - isViewSupportedByCamera('clips', cameraManager, foldersManager, view.camera) + return this._shouldShowEventMediaButton( + 'clips', + config.menu.buttons.clips, + cameraManager, + foldersManager, + view, + ) ? { icon: 'mdi:filmstrip', ...config.menu.buttons.clips, + enabled: true, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.clips'), style: view?.is('clips') ? this._getEmphasizedStyle() : {}, @@ -271,11 +306,17 @@ export class MenuButtonController { foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return view && - isViewSupportedByCamera('snapshots', cameraManager, foldersManager, view.camera) + return this._shouldShowEventMediaButton( + 'snapshots', + config.menu.buttons.snapshots, + cameraManager, + foldersManager, + view, + ) ? { icon: 'mdi:camera', ...config.menu.buttons.snapshots, + enabled: true, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.snapshots'), style: view?.is('snapshots') ? this._getEmphasizedStyle() : {}, @@ -305,6 +346,26 @@ export class MenuButtonController { : null; } + protected _getReviewsButton( + config: AdvancedCameraCardConfig, + cameraManager: CameraManager, + foldersManager: FoldersManager, + view?: View | null, + ): MenuItem | null { + return view && + isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera) + ? { + icon: 'mdi:play-box-multiple', + ...config.menu.buttons.reviews, + type: 'custom:advanced-camera-card-menu-icon', + title: localize('config.view.views.reviews'), + style: view.is('reviews') ? this._getEmphasizedStyle() : {}, + tap_action: createViewAction('reviews'), + hold_action: createViewAction('review'), + } + : null; + } + protected _getImageButton( config: AdvancedCameraCardConfig, cameraManager: CameraManager, @@ -365,6 +426,49 @@ export class MenuButtonController { return null; } + protected _getInfoButton( + config: AdvancedCameraCardConfig, + _cameraManager: CameraManager, + view?: View | null, + ): MenuItem | null { + const selectedItem = view?.queryResults?.getSelectedResult(); + if (!ViewItemClassifier.isMedia(selectedItem)) { + return null; + } + return { + icon: 'mdi:information-outline', + ...config.menu.buttons.info, + type: 'custom:advanced-camera-card-menu-icon', + title: localize('config.menu.buttons.info'), + tap_action: createGeneralAction('info'), + }; + } + + protected _getSetReviewButton( + config: AdvancedCameraCardConfig, + view?: View | null, + ): MenuItem | null { + const selectedItem = view?.queryResults?.getSelectedResult(); + if (!view?.isViewerView() || !ViewItemClassifier.isReview(selectedItem)) { + return null; + } + const isReviewed = selectedItem.isReviewed(); + if (isReviewed === null) { + return null; + } + + return { + icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline', + ...config.menu.buttons.set_review, + type: 'custom:advanced-camera-card-menu-icon', + title: isReviewed + ? localize('common.set_reviews.unreviewed') + : localize('common.set_reviews.reviewed'), + tap_action: createSetReviewAction(), + style: isReviewed ? this._getEmphasizedStyle() : {}, + }; + } + protected _getCameraUIButton( config: AdvancedCameraCardConfig, showCameraUIButton?: boolean, @@ -667,9 +771,8 @@ export class MenuButtonController { } if (folders.length === 1) { - const isSelected = - QueryClassifier.isFolderQuery(view?.query) && - view.query.getQuery()?.folder.id === folders[0][0]; + const folderID = folders[0][0]; + const isSelected = !!view?.query?.getFolderQueries(folderID).length; const folder = folders[0][1]; return { @@ -684,9 +787,7 @@ export class MenuButtonController { } const submenuItems = folders.map(([id, folder]) => { - const isSelected = - QueryClassifier.isFolderQuery(view?.query) && - view.query.getQuery()?.folder.id === id; + const isSelected = !!view?.query?.getFolderQueries(id).length; return { enabled: true, @@ -736,6 +837,7 @@ export class MenuButtonController { button: MenuItem, options?: MenuButtonControllerOptions, ): StyleInfo { + // Review for (const actionSet of [ button.tap_action, button.double_tap_action, @@ -752,7 +854,9 @@ export class MenuButtonController { VIEWS_USER_SPECIFIED.some( (viewName) => viewName === action.advanced_camera_card_action && - options?.view?.is(action.advanced_camera_card_action), + options?.view?.is( + action.advanced_camera_card_action as AdvancedCameraCardView, + ), ) || (action.advanced_camera_card_action === 'default' && options?.view?.is(config.view.default)) || diff --git a/src/components-lib/navigation.ts b/src/components-lib/navigation.ts new file mode 100644 index 00000000..d1a48f35 --- /dev/null +++ b/src/components-lib/navigation.ts @@ -0,0 +1,123 @@ +import { NonEmptyTuple } from 'type-fest'; +import { FolderPathComponent, FolderQuery } from '../card-controller/folders/types'; +import { ViewManagerEpoch, ViewModifier } from '../card-controller/view/types'; +import { localize } from '../localize/localize'; +import { ViewFolder, ViewMedia } from '../view/item'; +import { UnifiedQuery } from '../view/unified-query'; +import { UnifiedQueryBuilder } from '../view/unified-query-builder'; + +export interface FolderNavigationParamaters { + viewManagerEpoch: ViewManagerEpoch; + builder: UnifiedQueryBuilder; + + limit?: number; +} + +export interface MediaNavigationParamaters { + viewManagerEpoch: ViewManagerEpoch; + + modifiers?: ViewModifier[]; +} + +/** + * Get a navigable-up folder query from a UnifiedQuery. Returns the query only + * if there's exactly one folder query and it has depth > 1. + */ +const getSingleNavigableUpFolderQuery = ( + query?: UnifiedQuery | null, +): FolderQuery | null => { + const folderQueries = query?.getFolderQueries(); + if (folderQueries?.length !== 1) { + return null; + } + const folderQuery = folderQueries[0]; + return folderQuery.path.length > 1 ? folderQuery : null; +}; + +export const navigateUp = (options?: FolderNavigationParamaters | null): void => { + const folderQuery = getSingleNavigableUpFolderQuery( + options?.viewManagerEpoch.manager.getView()?.query, + ); + if (!folderQuery || folderQuery.path.length < 2) { + return; + } + + const parentPath = folderQuery.path.slice(0, -1); + const nonEmptyPath: NonEmptyTuple = [ + parentPath[0], + ...parentPath.slice(1), + ]; + const query = options?.builder.buildFolderQueryWithPath( + folderQuery.folder, + nonEmptyPath, + { + limit: options?.limit, + }, + ); + + options?.viewManagerEpoch.manager.setViewByParametersWithExistingQuery({ + params: { query }, + }); +}; + +export const navigateToFolder = ( + item: ViewFolder, + options?: FolderNavigationParamaters | null, +): void => { + const newPath = [...item.getPath(), { folder: item }]; + const nonEmptyPath: NonEmptyTuple = [ + newPath[0], + ...newPath.slice(1), + ]; + const query = options?.builder.buildFolderQueryWithPath( + item.getFolder(), + nonEmptyPath, + { + limit: options?.limit, + }, + ); + + options?.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ + params: { query }, + }); +}; + +export const navigateToMedia = ( + media: ViewMedia, + options?: MediaNavigationParamaters | null, +): void => { + const manager = options?.viewManagerEpoch.manager; + const view = manager?.getView(); + + if (!manager || !view?.queryResults || !options) { + return; + } + + const newResults = view.queryResults + .clone() + .selectResultIfFound((result) => result === media); + + const cameraID = media.getCameraID(); + manager.setViewByParameters({ + params: { + view: 'media', + queryResults: newResults, + ...(cameraID && { camera: cameraID }), + }, + modifiers: options?.modifiers, + }); +}; + +/** + * Get up-folder item for display. Returns a ViewFolder if there's exactly one + * folder query and it's navigable-up. + */ +export const getUpFolderItem = (query?: UnifiedQuery | null): ViewFolder | null => { + const folderQuery = getSingleNavigableUpFolderQuery(query); + return folderQuery + ? new ViewFolder(folderQuery.folder, folderQuery.path, { + icon: 'mdi:arrow-up-left', + title: localize('common.up'), + }) + : null; +}; diff --git a/src/components-lib/thumbnail/feature/controller.ts b/src/components-lib/thumbnail/feature/controller.ts index 5c53b90d..7e594d2c 100644 --- a/src/components-lib/thumbnail/feature/controller.ts +++ b/src/components-lib/thumbnail/feature/controller.ts @@ -76,7 +76,7 @@ export class ThumbnailFeatureController { if (thumbnail) { this._thumbnail = thumbnail; this._icon = null; - this._thumbnailClass = isBrandUrl(thumbnail) ? 'brand' : null; + this._thumbnailClass = isBrandUrl(thumbnail) ? 'placeholder' : null; } else { this._thumbnail = null; this._thumbnailClass = null; diff --git a/src/components-lib/timeline/controller.ts b/src/components-lib/timeline/controller.ts index 9821ebca..e99d3fd8 100644 --- a/src/components-lib/timeline/controller.ts +++ b/src/components-lib/timeline/controller.ts @@ -14,7 +14,6 @@ import { } from 'vis-timeline'; import { CameraManager } from '../../camera-manager/manager'; import { rangesOverlap } from '../../camera-manager/range'; -import { MediaQuery } from '../../camera-manager/types'; import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly'; import { FoldersManager } from '../../card-controller/folders/manager'; import { ViewItemManager } from '../../card-controller/view/item-manager'; @@ -41,22 +40,12 @@ import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index'; import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event'; import { ViewMedia } from '../../view/item'; import { ViewItemClassifier } from '../../view/item-classifier'; -import { - EventMediaQuery, - FolderViewQuery, - Query, - RecordingMediaQuery, -} from '../../view/query'; -import { QueryClassifier, QueryType } from '../../view/query-classifier'; import { QueryResults } from '../../view/query-results'; +import { UnifiedQuery } from '../../view/unified-query'; +import { UnifiedQueryTransformer } from '../../view/unified-query-transformer'; import { mergeViewContext } from '../../view/view'; import { AdvancedCameraCardTimelineItem, TimelineDataSource } from './source'; -import { - ExtendedTimeline, - TimelineItemClickAction, - TimelineKeys, - TimelineRangeChange, -} from './types'; +import { ExtendedTimeline, TimelineItemClickAction, TimelineRangeChange } from './types'; // An event used to fetch data required for thumbnail rendering. See special // note below on why this is necessary. @@ -81,7 +70,7 @@ interface TimelineControllerOptions { timelineConfig?: TimelineCoreConfig; mini?: boolean; thumbnailConfig?: ThumbnailsControlBaseConfig; - keys?: TimelineKeys; + query?: UnifiedQuery; } const TIMELINE_TARGET_BAR_ID = 'target_bar'; @@ -94,10 +83,14 @@ export class TimelineController { private _timeline: ExtendedTimeline | null = null; private _hass: HomeAssistant | null = null; + private _cameraManager: CameraManager | null = null; + private _foldersManager: FoldersManager | null = null; + private _viewItemManager: ViewItemManager | null = null; private _viewManagerEpoch: ViewManagerEpoch | null = null; private _timelineConfig: TimelineCoreConfig | null = null; + private _mini = false; private _panMode: TimelinePanMode | null = null; @@ -136,26 +129,56 @@ export class TimelineController { this._pointerHeld = null; } - public setOptions(options: TimelineControllerOptions): void { - this.destroyTimeline(); + /** + * Extract the "shape" of a query - a clone without time ranges. + * Shape determines timeline structure (groups). + */ + private _getQueryShape(query: UnifiedQuery): UnifiedQuery { + return UnifiedQueryTransformer.stripTimeRange(query); + } - if ( - options.keys && - options.cameraManager && - options.foldersManager && - options.conditionStateManager && - options.timelineConfig - ) { - this._source = new TimelineDataSource( - options.cameraManager, - options.foldersManager, - options.conditionStateManager, - options.keys, - options.timelineConfig.events_media_type, - options.timelineConfig.show_recordings, - ); - } else { - this._source = null; + private _hasSameShape(a?: UnifiedQuery | null, b?: UnifiedQuery | null): boolean { + if (!a && !b) { + return true; + } + if (!a || !b) { + return false; + } + return a.isEqual(b); + } + + public setOptions(options: TimelineControllerOptions): void { + // Extract the shape (query without time ranges) for comparison. + const newShape = options.query ? this._getQueryShape(options.query) : null; + + // Rebuild source if config, dependencies, or shape changed. + const needsRebuild = + !this._source || + this._cameraManager !== (options.cameraManager ?? null) || + this._foldersManager !== (options.foldersManager ?? null) || + !isEqual(this._timelineConfig, options.timelineConfig ?? null) || + !this._hasSameShape(this._source?.shape, newShape); + + if (needsRebuild) { + this.destroyTimeline(); + + if ( + newShape && + options.cameraManager && + options.foldersManager && + options.conditionStateManager && + options.timelineConfig + ) { + this._source = new TimelineDataSource( + options.cameraManager, + options.foldersManager, + options.conditionStateManager, + newShape, + options.timelineConfig.show_recordings, + ); + } else { + this._source = null; + } } if (this._thumbnailConfig !== (options.thumbnailConfig ?? null)) { @@ -187,6 +210,7 @@ export class TimelineController { this._thumbnailConfig = options?.thumbnailConfig ?? null; this._cameraManager = options?.cameraManager ?? null; + this._foldersManager = options?.foldersManager ?? null; this._viewItemManager = options?.viewItemManager ?? null; this._timelineConfig = options?.timelineConfig ?? null; this._mini = options?.mini ?? false; @@ -268,13 +292,13 @@ export class TimelineController { this._timeline = new Timeline( this._timelineElement, this._source.dataset, + this._source.groups, options, ); } else { this._timeline = new Timeline( this._timelineElement, this._source.dataset, - this._source.groups, options, ); } @@ -500,16 +524,15 @@ export class TimelineController { } const view = this._viewManagerEpoch?.manager.getView(); - const id = String(properties.item); - const item = this._source?.dataset.get(id) ?? null; + const id = properties.item ? String(properties.item) : null; + const item = id ? this._source?.dataset.get(id) ?? null : null; if ( this._ignoreClick || !view || !this._viewManagerEpoch || !this._source || - !properties.what || - !item + !properties.what ) { return; } @@ -519,9 +542,15 @@ export class TimelineController { if ( this._timelineConfig?.show_recordings && properties.time && - ['background', 'axis'].includes(properties.what) + ['background', 'axis'].includes(properties.what) && + this._source && + this._timeline ) { - const query = this._createQuery('recording'); + const query = this._source.buildRecordingsWindowedQuery( + convertRangeToCacheFriendlyTimes( + this._getPrefetchWindow(this._timeline.getWindow()), + ), + ); if (query) { await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ baseView: view, @@ -533,9 +562,14 @@ export class TimelineController { }, }, }, + modifiers: [ + new MergeContextViewModifier({ + mediaViewer: { seek: properties.time }, + }), + ], }); } - } else if (properties.item && properties.what === 'item') { + } else if (item && properties.what === 'item') { const cameraID = String(properties.group); const criteria = { @@ -566,49 +600,16 @@ export class TimelineController { // - If a folder media was loaded into the timeline from a prior folder // query other than the one stored in the view (e.g. user navigated to // a different folder in the thumbnails carousel). - if (item.query) { - // Item has a reference query (e.g. folders), use that. - const media = this._source.dataset - .get({ - filter: (timelineItem) => item.query === timelineItem.query, - }) - .map((timelineItem) => timelineItem.media) - .filter(isTruthy); - const selectedIndex = media.findIndex((m) => m.getID() === id); - - if (selectedIndex >= 0) { - const queryResults = new QueryResults({ results: media, selectedIndex }); - this._viewManagerEpoch?.manager.setViewByParameters({ - params: { - view: 'media', - query: item.query, - queryResults, - }, - modifiers: [new MergeContextViewModifier(context)], - }); - } - } else { - const currentQueryType = - QueryClassifier.getQueryType(view.query) ?? - this._source.getKeyType() === 'camera' - ? 'event' - : this._source.getKeyType() === 'folder' - ? 'folder' - : null; - - const query = currentQueryType ? this._createQuery(currentQueryType) : null; - if (query) { - await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ - params: { view: 'media', query: query }, - queryExecutorOptions: { - selectResult: { - id, - }, - rejectResults: (results) => !results.hasResults(), - }, - modifiers: [new MergeContextViewModifier(context)], - }); - } + const queryResults = this._buildQueryResultsFromExistingItem(item); + if (item && item.query && queryResults) { + this._viewManagerEpoch?.manager.setViewByParameters({ + params: { + view: 'media', + query: item.query, + queryResults, + }, + modifiers: [new MergeContextViewModifier(context)], + }); } } else { this._viewManagerEpoch.manager.setViewByParameters({ @@ -630,6 +631,25 @@ export class TimelineController { this._ignoreClick = false; } + private _buildQueryResultsFromExistingItem( + item: AdvancedCameraCardTimelineItem, + ): QueryResults | null { + const query = item.query; + if (!query || !this._source) { + return null; + } + const media = this._source.dataset + .get({ + filter: (timelineItem) => !!timelineItem.query && query === timelineItem.query, + }) + .map((timelineItem) => timelineItem.media) + .filter(isTruthy); + const selectedIndex = media.findIndex((m) => m.getID() === item.id); + return selectedIndex >= 0 + ? new QueryResults({ results: media, selectedIndex }) + : null; + } + /** * Get a broader prefetch window from a start and end basis. * @param window The window to broaden. @@ -643,31 +663,19 @@ export class TimelineController { }; } - private _createQuery( - type: QueryType, - options?: { - window?: TimelineWindow; - }, - ): Query | null { - if (!this._timeline || !this._source) { - return null; - } - - const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes( - this._getPrefetchWindow(options?.window ?? this._timeline.getWindow()), - ); - - if (type === 'event') { - const queries = this._source.getTimelineEventQueries(cacheFriendlyWindow); - return queries ? new EventMediaQuery(queries) : null; - } else if (type === 'recording') { - const queries = this._source.getTimelineRecordingQueries(cacheFriendlyWindow); - return queries ? new RecordingMediaQuery(queries) : null; - } else if (type === 'folder') { - const queries = this._source.getTimelineFolderQuery(); - return queries ? new FolderViewQuery(queries) : null; - } - return null; + /** + * Apply a cache-friendly prefetch window to all media queries. + */ + private _applyWindowToQuery( + query: UnifiedQuery, + window: TimelineWindow, + ): UnifiedQuery { + const prefetchWindow = this._getPrefetchWindow(window); + const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(prefetchWindow); + return UnifiedQueryTransformer.rebuildQuery(query, { + start: cacheFriendlyWindow.start, + end: cacheFriendlyWindow.end, + }); } private _timelineRangeChangedHandler = async (properties: { @@ -690,16 +698,14 @@ export class TimelineController { return; } - await this._source?.refresh(this._getPrefetchWindow(properties), { - view, - }); + await this._source?.refresh(this._getPrefetchWindow(properties)); - const queryType = QueryClassifier.getQueryType(view.query); - if (!queryType) { + if (!view.query) { return; } - const query = this._createQuery(queryType); - if (!query || this._alreadyHasAcceptableMediaQuery(query)) { + const query = this._applyWindowToQuery(view.query, properties); + + if (this._alreadyHasAcceptableMediaQuery(query)) { return; } @@ -720,27 +726,22 @@ export class TimelineController { }); }; - private _alreadyHasAcceptableMediaQuery(freshQuery: Query): boolean { + private _alreadyHasAcceptableMediaQuery(freshQuery: UnifiedQuery): boolean { const view = this._viewManagerEpoch?.manager.getView(); const query = view?.query; - if (!this._cameraManager || !query) { + if (!this._source || !query) { return false; } const currentResultTimestamp = view?.queryResults?.getResultsTimestamp(); + if (!currentResultTimestamp) { + return false; + } return ( - !!query?.getQuery() && - !!currentResultTimestamp && - ((QueryClassifier.isFolderQuery(query) && query.isEqual(freshQuery)) || - (QueryClassifier.isMediaQuery(query) && - QueryClassifier.isMediaQuery(freshQuery) && - query.isSupersetOf(freshQuery) && - this._cameraManager.areMediaQueriesResultsFresh( - currentResultTimestamp, - query.getQuery(), - ))) + query.isSupersetOf(freshQuery) && + this._source.areResultsFresh(currentResultTimestamp, query) ); } @@ -787,16 +788,14 @@ export class TimelineController { } const prefetchedWindow = this._getPrefetchWindow(desiredWindow); - if (!this._pointerHeld) { + if (!this._pointerHeld && view.query) { // Don't fetch any data or touch the timeline in any way if the user is // currently interacting with it. Without this the subsequent data fetches // (via fetchIfNecessary) may update the timeline contents which causes // the visjs timeline to stop dragging/panning operations which is very // disruptive to the user. - await this._source?.refresh(prefetchedWindow, { - view, - }); - this._source.addEventMediaToDataset(view.queryResults?.getResults(), view.query); + await this._source?.refresh(prefetchedWindow); + this._source.addMediaToDataset(view.query, view.queryResults?.getResults()); } const currentSelection = this._timeline.getSelection(); @@ -845,14 +844,11 @@ export class TimelineController { // // Also don't generate thumbnails in mini-timelines (they will already have // been generated). - const queryType = QueryClassifier.getQueryType(view.query); - if (!queryType) { + if (!view.query) { return; } - const freshMediaQuery = this._createQuery(queryType, { - window: desiredWindow, - }); + const freshMediaQuery = this._applyWindowToQuery(view.query, desiredWindow); if ( !this._mini && diff --git a/src/components-lib/timeline/source.ts b/src/components-lib/timeline/source.ts index b897a3a5..28642bf7 100644 --- a/src/components-lib/timeline/source.ts +++ b/src/components-lib/timeline/source.ts @@ -1,31 +1,25 @@ import { add, sub } from 'date-fns'; import { DataSet } from 'vis-data'; import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; -import { EqualityCache } from '../../cache/equality-cache'; import { CameraManager } from '../../camera-manager/manager'; import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet, } from '../../camera-manager/range'; -import { - EventQuery, - RecordingQuery, - RecordingSegment, -} from '../../camera-manager/types'; +import { RecordingSegment } from '../../camera-manager/types'; import { capEndDate } from '../../camera-manager/utils/cap-end-date'; import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly'; import { FoldersManager } from '../../card-controller/folders/manager'; -import { FolderQuery } from '../../card-controller/folders/types'; import { ConditionStateManagerReadonlyInterface } from '../../conditions/types'; import { FolderConfig } from '../../config/schema/folders'; -import { ClipsOrSnapshotsOrAll } from '../../types'; -import { errorToConsole, ModifyInterface } from '../../utils/basic.js'; +import { errorToConsole } from '../../utils/basic.js'; import { ViewItem, ViewMedia } from '../../view/item'; import { ViewItemClassifier } from '../../view/item-classifier'; -import { FolderViewQuery, Query } from '../../view/query'; -import { View } from '../../view/view'; -import { TimelineKeys } from './types'; +import { UnifiedQuery } from '../../view/unified-query'; +import { UnifiedQueryBuilder } from '../../view/unified-query-builder'; +import { UnifiedQueryRunner } from '../../view/unified-query-runner'; +import { UnifiedQueryTransformer } from '../../view/unified-query-transformer'; // Allow timeline freshness to be at least this number of seconds out of date // (caching times in the data-engine may increase the effective delay). @@ -37,9 +31,7 @@ const TIMELINE_FRESHNESS_TOLERANCE_SECONDS = 30; // instead of clean recording blocks. const TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS = 60; -type TimelineViewQuery = Query; - -export interface AdvancedCameraCardTimelineItem extends TimelineItem { +export type AdvancedCameraCardTimelineItem = TimelineItem & { // Use numbers to avoid significant volumes of Date object construction (for // high-quantity recording segments). start: number; @@ -48,25 +40,33 @@ export interface AdvancedCameraCardTimelineItem extends TimelineItem { // DataSet requires string (not HTMLElement) content. content: string; - media?: ViewMedia; - - // View query object from which this timeline item is associated with. - query?: TimelineViewQuery; -} + // Severity is duplicated here (also available via media.getSeverity()) + // because vis-timeline's dataAttributes option requires properties to exist + // directly on the item object to render them as data-* HTML attributes for + // CSS styling. + severity?: string; +} & ( // Ensure that if there's a media item there is a query it is associated with. + | { + media: ViewMedia; + query: UnifiedQuery; + } + | { + media?: never; + query?: never; + } + ); interface AdvancedCameraCardGroup { id: string; content: string; } -interface RefreshOptions { - view?: View; -} - export class TimelineDataSource { private _cameraManager: CameraManager; - private _foldersManager: FoldersManager; - private _conditionStateManager: ConditionStateManagerReadonlyInterface; + + private _builder: UnifiedQueryBuilder; + private _runner: UnifiedQueryRunner; + private _dataset: DataSet = new DataSet(); private _groups: DataSet; @@ -76,33 +76,34 @@ export class TimelineDataSource { // high-N segments into a smaller number of consecutive recording blocks). private _recordingRanges = new MemoryRangeSet(); - // Cache event ranges since re-adding the same events is a timeline - // performance killer (even if the request results are cached). - private _eventRanges = new ExpiringMemoryRangeSet(); - private _folderCache = new EqualityCache(); + // Cache for all query results in this source instance. + // Uses a single range set since shape determines content type. + private _cache = new ExpiringMemoryRangeSet(); - private _eventsMediaType: ClipsOrSnapshotsOrAll; private _showRecordings: boolean; - private _keys: TimelineKeys; + // The "shape" of the query, a UnifiedQuery without time ranges. Determines + // the groups/structure of the timeline. + private _shape: UnifiedQuery; constructor( cameraManager: CameraManager, foldersManager: FoldersManager, conditionStateManager: ConditionStateManagerReadonlyInterface, - keys: TimelineKeys, - eventsMediaType: ClipsOrSnapshotsOrAll, + shape: UnifiedQuery, showRecordings: boolean, ) { this._cameraManager = cameraManager; - this._foldersManager = foldersManager; - this._conditionStateManager = conditionStateManager; - this._keys = keys; - - this._groups = this._generateGroups(keys); - - this._eventsMediaType = eventsMediaType; + this._builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + this._runner = new UnifiedQueryRunner( + cameraManager, + foldersManager, + conditionStateManager, + ); + this._shape = shape; this._showRecordings = showRecordings; + + this._groups = this._generateGroups(); } get dataset(): DataSet { @@ -113,8 +114,12 @@ export class TimelineDataSource { return this._groups; } - public getKeyType(): 'camera' | 'folder' { - return this._keys.type; + get shape(): UnifiedQuery { + return this._shape; + } + + public areResultsFresh(resultsTimestamp: Date, query: UnifiedQuery): boolean { + return this._runner.areResultsFresh(resultsTimestamp, query); } private _getGroupIDForCamera(cameraID: string): string { @@ -122,30 +127,32 @@ export class TimelineDataSource { } private _getGroupIDForFolder(folderConfig: FolderConfig): string { - return folderConfig.id; + return `folder/${folderConfig.id}`; } - private _generateGroups(keys: TimelineKeys): DataSet { + private _generateGroups(): DataSet { const groups: AdvancedCameraCardGroup[] = []; - /* istanbul ignore else: the else path cannot be reached -- @preserve */ - if (keys.type === 'camera') { - keys.cameraIDs?.forEach((cameraID) => { - const cameraMetadata = this._cameraManager.getCameraMetadata(cameraID); - - groups.push({ - id: this._getGroupIDForCamera(cameraID), - content: cameraMetadata?.title ?? cameraID, - }); - }); - } else if (keys.type === 'folder') { - const folderID = this._getGroupIDForFolder(keys.folder); + // Add folder-based groups + const folderQueries = this._shape.getFolderQueries(); + for (const folderQuery of folderQueries) { + const folderID = this._getGroupIDForFolder(folderQuery.folder); groups.push({ id: folderID, - content: keys.folder?.title ?? folderID, + content: folderQuery.folder.title ?? folderID, }); } + // Add camera-based groups + const cameraIDs = this._shape.getAllCameraIDs(); + cameraIDs.forEach((cameraID) => { + const cameraMetadata = this._cameraManager.getCameraMetadata(cameraID); + groups.push({ + id: this._getGroupIDForCamera(cameraID), + content: cameraMetadata?.title ?? cameraID, + }); + }); + return new DataSet(groups); } @@ -164,14 +171,11 @@ export class TimelineDataSource { } } - public addEventMediaToDataset( - mediaArray?: ViewItem[] | null, - query?: TimelineViewQuery | null, - ): void { + public addMediaToDataset(query: UnifiedQuery, mediaArray?: ViewItem[] | null): void { const data: AdvancedCameraCardTimelineItem[] = []; for (const media of mediaArray ?? []) { - if (!ViewItemClassifier.isEvent(media)) { + if (!ViewItemClassifier.isEvent(media) && !ViewItemClassifier.isReview(media)) { continue; } @@ -193,7 +197,10 @@ export class TimelineDataSource { start: startTime.getTime(), type: 'range', end: media.getUsableEndTime()?.getTime(), - ...(query && { query }), + ...(ViewItemClassifier.isReview(media) && { + severity: media.getSeverity() ?? undefined, + }), + query, }); } } @@ -201,134 +208,60 @@ export class TimelineDataSource { this._dataset.update(data); } - private async _refreshEvents( - window: TimelineWindow, - options?: RefreshOptions, - ): Promise { - await this._refreshEventsFromCamera(window, options); - await this._refreshEventsFromFolder(); + public buildRecordingsWindowedQuery(window: TimelineWindow): UnifiedQuery | null { + return this._builder.buildRecordingsQuery(this._shape.getAllCameraIDs(), { + start: window.start, + end: window.end, + }); } - private async _refreshEventsFromCamera( - window: TimelineWindow, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _options?: RefreshOptions, - ): Promise { - if (this._keys.type !== 'camera') { - return; - } + private async _refreshQuery(window: TimelineWindow): Promise { + const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window); if ( - this._eventRanges.hasCoverage({ - start: window.start, - end: sub(capEndDate(window.end), { + this._cache.hasCoverage({ + start: cacheFriendlyWindow.start, + end: sub(capEndDate(cacheFriendlyWindow.end), { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS, }), }) ) { return; } - const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window); - const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow); - if (!eventQueries) { - return; - } - this.addEventMediaToDataset( - await this._cameraManager.executeMediaQueries(eventQueries), - ); + const query = UnifiedQueryTransformer.rebuildQuery(this._shape, { + start: cacheFriendlyWindow.start, + end: cacheFriendlyWindow.end, + }); - this._eventRanges.add({ + this.addMediaToDataset(query, await this._runner.execute(query)); + this._cache.add({ ...cacheFriendlyWindow, expires: add(new Date(), { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS }), }); } - private async _refreshEventsFromFolder(): Promise { - if (this._keys.type !== 'folder') { - return; - } - - const folderQuery = this.getTimelineFolderQuery(); - if (!folderQuery) { - return; - } - - const lastDate = this._folderCache.get(folderQuery); - const now = new Date(); - - if ( - lastDate && - lastDate >= sub(now, { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS }) - ) { - return; - } - - this.addEventMediaToDataset( - await this._foldersManager.expandFolder( - folderQuery, - this._conditionStateManager.getState(), - ), - new FolderViewQuery(folderQuery), - ); - this._folderCache.set(folderQuery, now); - } - - public async refresh(window: TimelineWindow, options?: RefreshOptions): Promise { + public async refresh(window: TimelineWindow): Promise { try { await Promise.all([ - this._refreshEvents(window, options), + this._refreshQuery(window), ...(this._showRecordings ? [this._refreshRecordings(window)] : []), ]); } catch (e) { errorToConsole(e as Error); - - // Intentionally ignore errors here, since it is likely the user will - // change the range again and a subsequent call may work. To do otherwise - // would be jarring to the timeline experience in the case of transient - // errors from the backend. } } - public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null { - if (this._keys.type !== 'camera' || !this._keys.cameraIDs.size) { - return null; - } - return this._cameraManager.generateDefaultEventQueries(this._keys.cameraIDs, { - start: window.start, - end: window.end, - ...(this._eventsMediaType === 'clips' && { hasClip: true }), - ...(this._eventsMediaType === 'snapshots' && { hasSnapshot: true }), - }); - } - - public getTimelineRecordingQueries(window: TimelineWindow): RecordingQuery[] | null { - if (this._keys.type !== 'camera' || !this._keys.cameraIDs.size) { - return null; - } - return this._cameraManager.generateDefaultRecordingQueries(this._keys.cameraIDs, { - start: window.start, - end: window.end, - }); - } - - public getTimelineFolderQuery(): FolderQuery | null { - if (this._keys.type !== 'folder') { - return null; - } - return this._foldersManager.generateDefaultFolderQuery(this._keys.folder); - } - private async _refreshRecordings(window: TimelineWindow): Promise { - const cameraIDs = this._keys.type === 'camera' ? this._keys.cameraIDs : null; + // Recordings only apply to camera-based shapes + const cameraIDs = this._shape.getAllCameraIDs(); if (!cameraIDs?.size) { return; } - type AdvancedCameraCardTimelineItemWithEnd = ModifyInterface< - AdvancedCameraCardTimelineItem, - { end: number } - >; + type AdvancedCameraCardTimelineItemWithEnd = AdvancedCameraCardTimelineItem & { + end: number; + }; const convertSegmentToRecording = ( cameraID: string, diff --git a/src/components-lib/timeline/types.ts b/src/components-lib/timeline/types.ts index 511e1595..2f53a556 100644 --- a/src/components-lib/timeline/types.ts +++ b/src/components-lib/timeline/types.ts @@ -3,7 +3,6 @@ import { CameraManager } from '../../camera-manager/manager'; import { ViewItemManager } from '../../card-controller/view/item-manager'; import { ViewManagerEpoch } from '../../card-controller/view/types'; import { CameraConfig } from '../../config/schema/cameras'; -import { FolderConfig } from '../../config/schema/folders'; import { HomeAssistant } from '../../ha/types'; import { ViewMedia } from '../../view/item'; @@ -21,16 +20,6 @@ export interface ThumbnailDataRequest { export class ThumbnailDataRequestEvent extends CustomEvent {} -export type TimelineKeys = - | { - type: 'camera'; - cameraIDs: Set; - } - | { - type: 'folder'; - folder: FolderConfig; - }; - export interface ExtendedTimeline extends Timeline { // setCustomTimeMarker currently missing from Timeline types. setCustomTimeMarker?(time: DateType, id?: IdType): void; diff --git a/src/components/carousel.ts b/src/components/carousel.ts index b7f3e2f8..d2ae0d46 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -52,6 +52,9 @@ export class AdvancedCameraCardCarousel extends LitElement { protected _refRoot: Ref = createRef(); protected _carousel: CarouselController | null = null; + // Track slide count to distinguish user-navigation from content changes. + protected _previousSlideCount: number | null = null; + connectedCallback(): void { super.connectedCallback(); @@ -100,6 +103,8 @@ export class AdvancedCameraCardCarousel extends LitElement { } protected updated(changedProps: PropertyValues): void { + const currentSlideCount = this._refParent.value?.assignedElements().length ?? 0; + if ( !this._carousel && this._refRoot.value && @@ -127,9 +132,21 @@ export class AdvancedCameraCardCarousel extends LitElement { wheelScrolling: this.wheelScrolling, }, ); - } else if (changedProps.has('selected')) { - this._carousel?.selectSlide(this.selected); + this._previousSlideCount = currentSlideCount; + } else if (changedProps.has('selected') && this._carousel) { + // Only scroll to selection if the slide count hasn't changed. + // This distinguishes between: + // - Navigation (next/prev): count same → scroll to selection + // - Content change (item removed): count changed → preserve position + const contentChanged = + this._previousSlideCount !== null && + this._previousSlideCount !== currentSlideCount; + + if (!contentChanged && this.selected !== this._carousel.getSelectedIndex()) { + this._carousel.selectSlide(this.selected); + } } + this._previousSlideCount = currentSlideCount; } static get styles(): CSSResultGroup { diff --git a/src/components/gallery/folder-gallery.ts b/src/components/gallery/folder-gallery.ts deleted file mode 100644 index 178600a3..00000000 --- a/src/components/gallery/folder-gallery.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS, -} from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { FoldersManager } from '../../card-controller/folders/manager.js'; -import { ViewItemManager } from '../../card-controller/view/item-manager.js'; -import { ViewManagerEpoch } from '../../card-controller/view/types.js'; -import { - getUpFolderMediaItem, - upFolderClickHandler, -} from '../../components-lib/folder/up-folder.js'; -import { FolderGalleryController } from '../../components-lib/gallery/folder-gallery-controller.js'; -import { MediaGalleryConfig } from '../../config/schema/media-gallery.js'; -import { HomeAssistant } from '../../ha/types.js'; -import { localize } from '../../localize/localize'; -import folderGalleryStyle from '../../scss/folder-gallery.scss'; -import { ViewItem } from '../../view/item.js'; -import '../media-filter'; -import '../message.js'; -import { renderMessage } from '../message.js'; -import '../surround-basic'; -import '../thumbnail/thumbnail.js'; -import './gallery-core.js'; - -@customElement('advanced-camera-card-folder-gallery') -export class AdvancedCameraCardFolderGallery extends LitElement { - @property({ attribute: false }) - public hass?: HomeAssistant; - - @property({ attribute: false }) - public viewManagerEpoch?: ViewManagerEpoch; - - @property({ attribute: false }) - public viewItemManager?: ViewItemManager; - - @property({ attribute: false }) - public galleryConfig?: MediaGalleryConfig; - - @property({ attribute: false }) - public foldersManager?: FoldersManager; - - protected _controller = new FolderGalleryController(this); - - protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('galleryConfig')) { - this._controller.setThumbnailSize(this.galleryConfig?.controls.thumbnails.size); - } - } - - protected _renderThumbnail( - item: ViewItem, - selected: boolean, - clickCallback: (item: ViewItem, ev: Event) => void, - ): TemplateResult | void { - return html` clickCallback(item, ev)} - > - `; - } - - protected _renderThumbnails(): TemplateResult | void { - const selected = this.viewManagerEpoch?.manager - .getView() - ?.queryResults?.getSelectedResult(); - - return html` - ${this.viewManagerEpoch?.manager - .getView() - ?.queryResults?.getResults() - ?.map((item) => - this._renderThumbnail(item, item === selected, (item: ViewItem, ev: Event) => { - const manager = this.viewManagerEpoch?.manager; - if (manager) { - this._controller.itemClickHandler(manager, item, ev, this.foldersManager); - } - }), - )} - `; - } - - protected render(): TemplateResult | void { - const folderIsLoading = - !!this.viewManagerEpoch?.manager.getView()?.context?.loading?.query; - const upThumbnail = getUpFolderMediaItem(this.viewManagerEpoch?.manager.getView()); - - return html` - - ${!this.viewManagerEpoch?.manager.getView()?.queryResults?.hasResults() && - (folderIsLoading || !upThumbnail) - ? renderMessage({ - type: 'info', - message: folderIsLoading - ? localize('error.awaiting_folder') - : localize('common.no_folder'), - icon: 'mdi:folder-play', - dotdotdot: folderIsLoading, - }) - : html` - ${upThumbnail - ? this._renderThumbnail( - upThumbnail, - false, - (item: ViewItem, ev: Event) => - upFolderClickHandler(item, ev, this.viewManagerEpoch), - ) - : ''} - ${this._renderThumbnails()} - `} - - `; - } - - static get styles(): CSSResultGroup { - return unsafeCSS(folderGalleryStyle); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'advanced-camera-card-folder-gallery': AdvancedCameraCardFolderGallery; - } -} diff --git a/src/components/gallery/gallery.ts b/src/components/gallery/gallery.ts new file mode 100644 index 00000000..82740b2e --- /dev/null +++ b/src/components/gallery/gallery.ts @@ -0,0 +1,294 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { CameraManager } from '../../camera-manager/manager.js'; +import { FoldersManager } from '../../card-controller/folders/manager.js'; +import { ViewItemManager } from '../../card-controller/view/item-manager.js'; +import { MergeContextViewModifier } from '../../card-controller/view/modifiers/merge-context.js'; +import { RemoveContextViewModifier } from '../../card-controller/view/modifiers/remove-context.js'; +import { ViewManagerEpoch } from '../../card-controller/view/types.js'; +import { GalleryController } from '../../components-lib/gallery/controller.js'; +import { + FolderNavigationParamaters, + getUpFolderItem, + navigateToFolder, + navigateToMedia, + navigateUp, +} from '../../components-lib/navigation.js'; +import { ConditionStateManagerReadonlyInterface } from '../../conditions/types.js'; +import { MediaGalleryConfig } from '../../config/schema/media-gallery.js'; +import { CardWideConfig } from '../../config/schema/types.js'; +import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const.js'; +import { HomeAssistant } from '../../ha/types.js'; +import { localize } from '../../localize/localize.js'; +import galleryStyle from '../../scss/media-gallery.scss'; +import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; +import { ViewItemClassifier } from '../../view/item-classifier.js'; +import { ViewFolder, ViewItem } from '../../view/item.js'; +import { UnifiedQueryBuilder } from '../../view/unified-query-builder.js'; +import { UnifiedQueryRunner } from '../../view/unified-query-runner.js'; +import '../media-filter.js'; +import '../message.js'; +import { renderMessage } from '../message.js'; +import '../surround-basic.js'; +import '../thumbnail/thumbnail.js'; +import './gallery-core.js'; +import { GalleryExtendEvent } from './types.js'; + +const GALLERY_FILTER_MENU_ICONS = { + closed: 'mdi:filter-cog-outline', + open: 'mdi:filter-cog', +}; + +@customElement('advanced-camera-card-gallery') +export class AdvancedCameraCardGallery extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public viewManagerEpoch?: ViewManagerEpoch; + + @property({ attribute: false }) + public galleryConfig?: MediaGalleryConfig; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public foldersManager?: FoldersManager; + + @property({ attribute: false }) + public viewItemManager?: ViewItemManager; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + @property({ attribute: false }) + public conditionStateManager?: ConditionStateManagerReadonlyInterface; + + protected _controller = new GalleryController(this); + protected _upFolderItem: ViewFolder | null = null; + protected _builder: UnifiedQueryBuilder | null = null; + + protected willUpdate(changedProps: PropertyValues): void { + if ( + (changedProps.has('cameraManager') || changedProps.has('foldersManager')) && + this.cameraManager && + this.foldersManager + ) { + this._builder = new UnifiedQueryBuilder(this.cameraManager, this.foldersManager); + } + + if (changedProps.has('viewManagerEpoch')) { + const view = this.viewManagerEpoch?.manager.getView(); + this._controller.setItemsFromView(view, this.viewManagerEpoch?.oldView); + this._upFolderItem = getUpFolderItem(view?.query); + } + + if (changedProps.has('galleryConfig')) { + this._controller.setThumbnailSize(this.galleryConfig?.controls.thumbnails.size); + } + } + + protected _getLimit(): number { + return ( + this.cardWideConfig?.performance?.features?.media_chunk_size ?? + MEDIA_CHUNK_SIZE_DEFAULT + ); + } + + protected _getFolderNavigationParameters(): FolderNavigationParamaters | null { + return this._builder && this.viewManagerEpoch + ? { + builder: this._builder, + viewManagerEpoch: this.viewManagerEpoch, + limit: this._getLimit(), + } + : null; + } + + protected _renderUpFolder(): TemplateResult | void { + if (!this._upFolderItem) { + return; + } + + return html` { + stopEventFromActivatingCardWideActions(ev); + navigateUp(this._getFolderNavigationParameters()); + }} + > + `; + } + + protected _renderThumbnails(): TemplateResult | void { + const selected = this.viewManagerEpoch?.manager + .getView() + ?.queryResults?.getSelectedResult(); + + return html` + ${this._controller.getItems()?.map( + (item) => + html` { + stopEventFromActivatingCardWideActions(ev); + if (ViewItemClassifier.isMedia(item) && this.viewManagerEpoch) { + navigateToMedia(item, { + viewManagerEpoch: this.viewManagerEpoch, + modifiers: [ + new RemoveContextViewModifier(['timeline', 'mediaViewer']), + new MergeContextViewModifier({ + gallery: { + originView: this.viewManagerEpoch.manager.getView()?.view, + }, + }), + ], + }); + } else if (ViewItemClassifier.isFolder(item)) { + navigateToFolder(item, this._getFolderNavigationParameters()); + } + }} + > + `, + )} + `; + } + + protected render(): TemplateResult | void { + const isLoading = + !!this.viewManagerEpoch?.manager.getView()?.context?.loading?.query; + + const hasItems = + (this._controller.getItems()?.length ?? 0) > 0 || !!this._upFolderItem; + + const showFilter = + this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none'; + + return html` + + ${showFilter && this.galleryConfig + ? html` + ` + : ''} + ${!hasItems + ? renderMessage({ + type: 'info', + message: isLoading + ? localize('error.awaiting_media') + : localize('common.no_media'), + icon: 'mdi:multimedia', + dotdotdot: isLoading, + }) + : html`, + ) => + this._extendGallery( + ev, + 'later', + // Avoid use of cache since the user is explicitly looking for + // the freshest possible data. + false, + )} + @advanced-camera-card:gallery:extend:down=${( + ev: CustomEvent, + ) => this._extendGallery(ev, 'earlier')} + > + ${this._renderUpFolder()} ${this._renderThumbnails()} + `} + + `; + } + + protected async _extendGallery( + ev: CustomEvent, + direction: 'earlier' | 'later', + useCache = true, + ): Promise { + if ( + !this.cameraManager || + !this.foldersManager || + !this.viewManagerEpoch || + !this.conditionStateManager + ) { + ev.detail.resolve(); + return; + } + + const runner = new UnifiedQueryRunner( + this.cameraManager, + this.foldersManager, + this.conditionStateManager, + ); + + await this._controller.extend(runner, this.viewManagerEpoch, direction, useCache); + ev.detail.resolve(); + } + + static get styles(): CSSResultGroup { + return unsafeCSS(galleryStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-gallery': AdvancedCameraCardGallery; + } + interface HTMLElementEventMap { + 'advanced-camera-card:media:reviewed': CustomEvent; + } +} diff --git a/src/components/gallery/media-gallery.ts b/src/components/gallery/media-gallery.ts deleted file mode 100644 index 5f4a68fc..00000000 --- a/src/components/gallery/media-gallery.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS, -} from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { CameraManager } from '../../camera-manager/manager.js'; -import { ViewItemManager } from '../../card-controller/view/item-manager.js'; -import { ViewManagerEpoch } from '../../card-controller/view/types.js'; -import { MediaGalleryController } from '../../components-lib/gallery/media-gallery-controller.js'; -import { MediaGalleryConfig } from '../../config/schema/media-gallery.js'; -import { CardWideConfig } from '../../config/schema/types.js'; -import { HomeAssistant } from '../../ha/types.js'; -import { localize } from '../../localize/localize'; -import mediaGalleryStyle from '../../scss/media-gallery.scss'; -import '../media-filter'; -import '../message.js'; -import { renderMessage } from '../message.js'; -import '../surround-basic'; -import '../thumbnail/thumbnail.js'; -import './gallery-core.js'; -import { GalleryExtendEvent } from './types.js'; - -const MEDIA_GALLERY_FILTER_MENU_ICONS = { - closed: 'mdi:filter-cog-outline', - open: 'mdi:filter-cog', -}; - -@customElement('advanced-camera-card-media-gallery') -export class AdvancedCameraCardMediaGallery extends LitElement { - @property({ attribute: false }) - public hass?: HomeAssistant; - - @property({ attribute: false }) - public viewManagerEpoch?: ViewManagerEpoch; - - @property({ attribute: false }) - public galleryConfig?: MediaGalleryConfig; - - @property({ attribute: false }) - public cameraManager?: CameraManager; - - @property({ attribute: false }) - public viewItemManager?: ViewItemManager; - - @property({ attribute: false }) - public cardWideConfig?: CardWideConfig; - - protected _controller = new MediaGalleryController(this); - - protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('viewManagerEpoch')) { - this._controller.setMediaFromView( - this.viewManagerEpoch?.manager.getView(), - this.viewManagerEpoch?.oldView, - ); - } - - if (changedProps.has('galleryConfig')) { - this._controller.setThumbnailSize(this.galleryConfig?.controls.thumbnails.size); - } - } - - protected _renderThumbnails(): TemplateResult | void { - const selected = this.viewManagerEpoch?.manager - .getView() - ?.queryResults?.getSelectedResult(); - - return html` - ${this._controller.getMedia()?.map( - (media, index) => - html` { - const manager = this.viewManagerEpoch?.manager; - if (manager) { - this._controller.itemClickHandler(manager, index, ev); - } - }} - > - `, - )} - `; - } - - protected render(): TemplateResult | void { - const mediaIsLoading = - !!this.viewManagerEpoch?.manager.getView()?.context?.loading?.query; - - return html` - - ${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none' - ? html` - ` - : ''} - ${!this._controller.getMedia()?.length - ? renderMessage({ - type: 'info', - message: mediaIsLoading - ? localize('error.awaiting_media') - : localize('common.no_media'), - icon: 'mdi:multimedia', - dotdotdot: mediaIsLoading, - }) - : html`, - ) => - this._extendGallery( - ev, - 'later', - // Avoid use of cache since the user is explicitly looking for - // the freshest possible data. - false, - )} - @advanced-camera-card:gallery:extend:down=${( - ev: CustomEvent, - ) => this._extendGallery(ev, 'earlier')} - > - ${this._renderThumbnails()} - `} - - `; - } - - protected async _extendGallery( - ev: CustomEvent, - direction: 'earlier' | 'later', - useCache = true, - ): Promise { - if (!this.cameraManager || !this.viewManagerEpoch) { - return; - } - await this._controller.extendMediaGallery( - this.cameraManager, - this.viewManagerEpoch, - direction, - useCache, - ); - ev.detail.resolve(); - } - - static get styles(): CSSResultGroup { - return unsafeCSS(mediaGalleryStyle); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'advanced-camera-card-media-gallery': AdvancedCameraCardMediaGallery; - } -} diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index cdb80ae9..c88e3345 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -11,12 +11,11 @@ import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { CameraManager } from '../camera-manager/manager'; +import { FoldersManager } from '../card-controller/folders/manager'; import { ViewManagerEpoch } from '../card-controller/view/types'; import { MediaFilterController, - MediaFilterCoreFavoriteSelection, MediaFilterCoreWhen, - MediaFilterMediaType, } from '../components-lib/media-filter-controller'; import { CardWideConfig } from '../config/schema/types'; import { HomeAssistant } from '../ha/types'; @@ -38,6 +37,9 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) { @property({ attribute: false }) public viewManagerEpoch?: ViewManagerEpoch; + @property({ attribute: false }) + public foldersManager?: FoldersManager; + @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @@ -56,6 +58,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) { protected _refWhat: Ref = createRef(); protected _refWhere: Ref = createRef(); protected _refFavorite: Ref = createRef(); + protected _refReviewed: Ref = createRef(); protected _refTags: Ref = createRef(); protected willUpdate(changedProps: PropertyValues): void { @@ -63,42 +66,56 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) { this._mediaFilterController.setViewManager(this.viewManagerEpoch?.manager ?? null); } - if (changedProps.has('cameraManager') && this.cameraManager) { - this._mediaFilterController.computeCameraOptions(this.cameraManager); + if ( + (changedProps.has('cameraManager') || changedProps.has('foldersManager')) && + this.cameraManager && + this.foldersManager + ) { + this._mediaFilterController.computeCameraOptions( + this.cameraManager, + this.foldersManager, + ); this._mediaFilterController.computeMetadataOptions(this.cameraManager); } // The first time the viewManager is set, compute the initial default selections. if ( - !changedProps.get('viewManager') && + (!changedProps.get('viewManager') || changedProps.has('foldersManager')) && this.viewManagerEpoch && - this.cameraManager + this.cameraManager && + this.foldersManager ) { - this._mediaFilterController.computeInitialDefaultsFromView(this.cameraManager); + this._mediaFilterController.computeInitialDefaultsFromView( + this.cameraManager, + this.foldersManager, + ); } } protected render(): TemplateResult | void { const valueChange = async () => { - if (!this.cameraManager || !this.viewManagerEpoch || !this.cardWideConfig) { + if ( + !this.cameraManager || + !this.foldersManager || + !this.viewManagerEpoch || + !this.cardWideConfig + ) { return; } await this._mediaFilterController.valueChangeHandler( this.cameraManager, + this.foldersManager, this.cardWideConfig, { camera: this._refCamera.value?.value ?? undefined, - mediaType: (this._refMediaType.value?.value ?? undefined) as - | MediaFilterMediaType - | undefined, + mediaTypes: this._refMediaType.value?.value ?? undefined, when: { selected: this._refWhen.value?.value ?? undefined, from: this._refWhenFrom.value?.value, to: this._refWhenTo.value?.value, }, - favorite: (this._refFavorite.value?.value ?? undefined) as - | MediaFilterCoreFavoriteSelection - | undefined, + favorite: this._refFavorite.value?.value ?? undefined, + reviewed: this._refReviewed.value?.value ?? undefined, where: this._refWhere.value?.value ?? undefined, what: this._refWhat.value?.value ?? undefined, tags: this._refTags.value?.value ?? undefined, @@ -126,7 +143,6 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) { return; } - const controls = this._mediaFilterController.getControlsToShow(this.cameraManager); const defaults = this._mediaFilterController.getDefaults(); const whatOptions = this._mediaFilterController.getWhatOptions(); const tagsOptions = this._mediaFilterController.getTagsOptions(); @@ -137,7 +153,9 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) { label=${localize('media_filter.media_type')} placeholder=${localize('media_filter.select_media_type')} .options=${this._mediaFilterController.getMediaTypeOptions()} - .initialValue=${defaults?.mediaType} + .initialValue=${defaults?.mediaTypes} + multiple + clearable @advanced-camera-card:select:change=${() => valueChange()} > @@ -184,7 +202,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) { @advanced-camera-card:select:change=${() => valueChange()} > - ${controls.events && whatOptions.length + ${whatOptions.length ? html` ` : ''} - ${controls.events && tagsOptions.length + ${tagsOptions.length ? html` ` : ''} - ${controls.events && whereOptions.length + ${whereOptions.length ? html` ` : ''} - ${controls.favorites - ? html` - valueChange()} - > - - ` - : ''}`; + valueChange()} + > + + valueChange()} + > + `; } static get styles(): CSSResultGroup { diff --git a/src/components/overlay-message.ts b/src/components/overlay-message.ts new file mode 100644 index 00000000..cce58369 --- /dev/null +++ b/src/components/overlay-message.ts @@ -0,0 +1,131 @@ +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { createRef, Ref, ref } from 'lit/directives/ref.js'; +import overlayMessageStyle from '../scss/overlay-message.scss'; +import { MetadataField, OverlayMessage, OverlayMessageControl } from '../types.js'; +import { dispatchDismissOverlayMessageEvent } from '../utils/overlay-message.js'; +import './icon.js'; + +@customElement('advanced-camera-card-overlay-message') +export class AdvancedCameraCardOverlayMessage extends LitElement { + @property({ attribute: false }) + public message: OverlayMessage | null = null; + + protected _refMessage: Ref = createRef(); + + public connectedCallback(): void { + super.connectedCallback(); + window.addEventListener('click', this._handleOutsideInteraction); + window.addEventListener('focusin', this._handleOutsideInteraction); + } + + public disconnectedCallback(): void { + window.removeEventListener('click', this._handleOutsideInteraction); + window.removeEventListener('focusin', this._handleOutsideInteraction); + super.disconnectedCallback(); + } + + protected render(): TemplateResult | void { + if (!this.message) { + return; + } + + const heading = this.message.heading; + const details = this.message.details ?? []; + const text = this.message.text; + const controls = this.message.controls ?? []; + + return html` +
+
+
+ ${heading ? this._renderDetail(heading, true) : ''} + ${details.map((detail) => this._renderDetail(detail))} + ${text ? html`
${text}
` : ''} +
+ ${controls.length + ? html`
+ ${controls.map((control) => this._renderControl(control))} +
` + : ''} +
+ +
+
+ `; + } + + protected _renderControl(control: OverlayMessageControl): TemplateResult { + const emphasisClass = control.emphasis ? `emphasis-${control.emphasis}` : ''; + return html` +
this._handleControlClick(control)} + > + ${control.icon + ? html`` + : ''} +
+ `; + } + + protected async _handleControlClick(control: OverlayMessageControl): Promise { + const result = await control.callback(); + if (result === null) { + // null = close the message + this._dismiss(); + } else { + // Updated message = keep open and refresh + this.message = result; + } + } + + protected _renderDetail(detail: MetadataField, isHeading = false): TemplateResult { + return html` +
+ ${detail.icon + ? html`` + : ''} + ${detail.title} +
+ `; + } + + protected _dismiss = (): void => { + this._refMessage.value?.classList.add('exiting'); + }; + + protected _handleAnimationEnd = (ev: AnimationEvent): void => { + if (ev.animationName === 'slideDown') { + dispatchDismissOverlayMessageEvent(this); + } + }; + + protected _handleOutsideInteraction = (ev: Event): void => { + if (!ev.composedPath().includes(this)) { + this._dismiss(); + } + }; + + static get styles(): CSSResultGroup { + return unsafeCSS(overlayMessageStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-overlay-message': AdvancedCameraCardOverlayMessage; + } +} diff --git a/src/components/select.ts b/src/components/select.ts index 576b9f23..850200e9 100644 --- a/src/components/select.ts +++ b/src/components/select.ts @@ -70,10 +70,11 @@ export class AdvancedCameraCardSelect extends ScopedRegistryHost(LitElement) { const initialValueSet = this.value === null; this.value = value; - // The underlying gr-select element will call on the first first value set + // The underlying gr-select element will call on the first value set // (even when the user has not interacted with the control). Do not - // dispatch events for this. - if (!initialValueSet) { + // dispatch events for this UNLESS the value is actually non-empty + // (indicating a user selection). + if (!initialValueSet || (Array.isArray(value) ? value.length : !!value)) { fireAdvancedCameraCardEvent(this, 'select:change', value); } } diff --git a/src/components/status-bar.ts b/src/components/status-bar.ts index 53384e99..65bf22f0 100644 --- a/src/components/status-bar.ts +++ b/src/components/status-bar.ts @@ -100,6 +100,7 @@ export class AdvancedCameraCardStatusBar extends LitElement { return html`
this._controller.actionHandler(ev, item.actions)} > ${item.string} @@ -109,6 +110,7 @@ export class AdvancedCameraCardStatusBar extends LitElement { .actionHandler=${handler} .icon=${{ icon: item.icon }} class="${classes}" + data-severity=${item.severity ?? ''} @action=${(ev) => this._controller.actionHandler(ev, item.actions)} >`; } else if (item.type === 'custom:advanced-camera-card-status-bar-image') { @@ -116,6 +118,7 @@ export class AdvancedCameraCardStatusBar extends LitElement { .actionHandler=${handler} class="${classes}" src="${item.image}" + data-severity=${item.severity ?? ''} @action=${(ev) => this._controller.actionHandler(ev, item.actions)} />`; } diff --git a/src/components/surround.ts b/src/components/surround.ts index 1ed21121..60534adf 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -7,22 +7,18 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { isEqual } from 'lodash-es'; import { CameraManager } from '../camera-manager/manager.js'; import { FoldersManager } from '../card-controller/folders/manager.js'; import { ViewItemManager } from '../card-controller/view/item-manager.js'; import { ViewManagerEpoch } from '../card-controller/view/types.js'; -import { TimelineKeys } from '../components-lib/timeline/types.js'; import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js'; import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js'; import { MiniTimelineControlConfig } from '../config/schema/common/controls/timeline.js'; -import { FolderConfig } from '../config/schema/folders.js'; import { CardWideConfig } from '../config/schema/types.js'; import { HomeAssistant } from '../ha/types.js'; import basicBlockStyle from '../scss/basic-block.scss'; import { contentsChanged } from '../utils/basic.js'; import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js'; -import { QueryClassifier } from '../view/query-classifier.js'; import './surround-basic.js'; import './thumbnail-carousel'; @@ -55,11 +51,8 @@ export class AdvancedCameraCardSurround extends LitElement { @property({ attribute: false }) public cardWideConfig?: CardWideConfig; - protected _timelineKeys?: TimelineKeys | null = null; - /** * Determine if a drawer is being used. - * @returns `true` if a drawer is used, `false` otherwise. */ protected _hasDrawer(): boolean { return ( @@ -67,80 +60,11 @@ export class AdvancedCameraCardSurround extends LitElement { ); } - protected willUpdate(changedProperties: PropertyValues): void { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected willUpdate(_changedProperties: PropertyValues): void { if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') { import('./timeline-core.js'); } - - // Only reset the timeline cameraIDs when the media or display mode - // materially changes (and not on every view change, since the view will - // change frequently when the user is scrubbing video). - const view = this.viewManagerEpoch?.manager.getView(); - if ( - changedProperties.has('viewManagerEpoch') && - (this.viewManagerEpoch?.manager.hasMajorMediaChange( - this.viewManagerEpoch?.oldView, - ) || - this.viewManagerEpoch?.oldView?.displayMode !== view?.displayMode) - ) { - const newKeys = this._getTimelineKeys(); - // Update only if changed, to avoid unnecessary timeline destructions. - if (!isEqual(newKeys, this._timelineKeys)) { - this._timelineKeys = newKeys ?? undefined; - } - } - } - - protected _getTimelineKeys(): TimelineKeys | null { - const cameraIDsToKeys = (cameraIDs: Set | null): TimelineKeys | null => { - return cameraIDs?.size - ? { - type: 'camera', - cameraIDs, - } - : null; - }; - - const folderToKeys = (folderConfig: FolderConfig): TimelineKeys => { - return { - type: 'folder', - folder: folderConfig, - }; - }; - - const view = this.viewManagerEpoch?.manager.getView(); - if (!view || !this.cameraManager) { - return null; - } - - if (view.is('live')) { - const capabilitySearch = { - anyCapabilities: ['clips' as const, 'snapshots' as const, 'recordings' as const], - }; - if (view.supportsMultipleDisplayModes() && view.isGrid()) { - return cameraIDsToKeys( - this.cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch), - ); - } else { - return cameraIDsToKeys( - this.cameraManager - .getStore() - .getAllDependentCameras(view.camera, capabilitySearch), - ); - } - } - - const queries = view.query; - if (view.isViewerView()) { - if (QueryClassifier.isMediaQuery(queries)) { - return cameraIDsToKeys(queries.getQueryCameraIDs()); - } else if (QueryClassifier.isFolderQuery(queries)) { - const folderConfig = queries.getQuery()?.folder; - return folderConfig ? folderToKeys(folderConfig) : null; - } - } - - return null; } protected render(): TemplateResult | void { @@ -175,7 +99,9 @@ export class AdvancedCameraCardSurround extends LitElement { .viewItemManager=${this.viewItemManager} .fadeThumbnails=${view.isViewerView()} .viewManagerEpoch=${this.viewManagerEpoch} + .foldersManager=${this.foldersManager} .selected=${view.queryResults?.getSelectedIndex() ?? undefined} + .cardWideConfig=${this.cardWideConfig} > ` : ''} @@ -189,7 +115,6 @@ export class AdvancedCameraCardSurround extends LitElement { this.thumbnailConfig?.mode === 'none' ? 'play' : 'select'} - .keys=${this._timelineKeys} .mini=${true} .timelineConfig=${this.timelineConfig} .thumbnailConfig=${this.thumbnailConfig} diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index c854168d..327ccf13 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -9,14 +9,20 @@ import { import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { CameraManager } from '../camera-manager/manager.js'; +import { FoldersManager } from '../card-controller/folders/manager.js'; import { ViewItemManager } from '../card-controller/view/item-manager.js'; import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js'; import { ViewManagerEpoch } from '../card-controller/view/types.js'; import { - getUpFolderMediaItem, - upFolderClickHandler, -} from '../components-lib/folder/up-folder.js'; + FolderNavigationParamaters, + getUpFolderItem, + navigateToFolder, + navigateToMedia, + navigateUp, +} from '../components-lib/navigation.js'; import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js'; +import { CardWideConfig } from '../config/schema/types.js'; +import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; import { HomeAssistant } from '../ha/types.js'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; @@ -24,7 +30,7 @@ import { CarouselDirection } from '../utils/embla/carousel-controller.js'; import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js'; import { ViewItemClassifier } from '../view/item-classifier.js'; import { ViewItem, ViewMedia } from '../view/item.js'; -import { QueryClassifier } from '../view/query-classifier.js'; +import { UnifiedQueryBuilder } from '../view/unified-query-builder.js'; import './carousel.js'; import './thumbnail/thumbnail.js'; @@ -43,18 +49,50 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public foldersManager?: FoldersManager; + @property({ attribute: false }) public viewItemManager?: ViewItemManager; @property({ attribute: false }) public config?: ThumbnailsControlConfig; + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + @property({ attribute: false }) public fadeThumbnails = false; protected _thumbnails: TemplateResult[] = []; + protected _builder: UnifiedQueryBuilder | null = null; + + protected _getLimit(): number { + return ( + this.cardWideConfig?.performance?.features?.media_chunk_size ?? + MEDIA_CHUNK_SIZE_DEFAULT + ); + } + + protected _getFolderNavOptions(): FolderNavigationParamaters | undefined { + return this._builder && this.viewManagerEpoch + ? { + builder: this._builder, + viewManagerEpoch: this.viewManagerEpoch, + limit: this._getLimit(), + } + : undefined; + } protected willUpdate(changedProps: PropertyValues): void { + if ( + (changedProps.has('cameraManager') || changedProps.has('foldersManager')) && + this.cameraManager && + this.foldersManager + ) { + this._builder = new UnifiedQueryBuilder(this.cameraManager, this.foldersManager); + } + if (changedProps.has('config')) { if (this.config?.size) { this.style.setProperty( @@ -94,53 +132,17 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { ); } - protected _itemClickCallback(item: ViewItem, ev: Event): void { - stopEventFromActivatingCardWideActions(ev); - - const view = this.viewManagerEpoch?.manager.getView(); - const query = view?.query; - const results = view?.queryResults; - - if (!view || !query || !results) { - return; - } - - if (ViewItemClassifier.isMedia(item)) { - const newResults = results - .clone() - .selectResultIfFound((result) => result === item); - const cameraID = item.getCameraID(); - - fireAdvancedCameraCardEvent( - this, - 'thumbnails-carousel:media-select', - { media: item }, - ); - this.viewManagerEpoch?.manager.setViewByParameters({ - params: { - view: 'media', - queryResults: newResults, - ...(cameraID && { camera: cameraID }), - }, + protected _handleMediaClick(item: ViewMedia): void { + fireAdvancedCameraCardEvent( + this, + 'thumbnails-carousel:media-select', + { media: item }, + ); + if (this.viewManagerEpoch) { + navigateToMedia(item, { + viewManagerEpoch: this.viewManagerEpoch, modifiers: [new RemoveContextViewModifier(['timeline', 'mediaViewer'])], }); - } else if ( - QueryClassifier.isFolderQuery(query) && - ViewItemClassifier.isFolder(item) - ) { - const rawQuery = query.getQuery(); - if (!rawQuery) { - return; - } - - this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ - params: { - query: query.clone().setQuery({ - folder: rawQuery.folder, - path: [...(rawQuery.path ?? []), { folder: item }], - }), - }, - }); } } @@ -171,31 +173,42 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { ?show_favorite_control=${this.config?.show_favorite_control} ?show_timeline_control=${this.config?.show_timeline_control} ?show_download_control=${this.config?.show_download_control} + ?show_review_control=${this.config?.show_review_control} + ?show_info_control=${this.config?.show_info_control} @click=${(ev: Event) => clickCallback(item, ev)} > `; } protected _renderThumbnails(): TemplateResult[] { - const upThumbnail = getUpFolderMediaItem(this.viewManagerEpoch?.manager.getView()); - const thumbnails: TemplateResult[] = [ - ...(upThumbnail - ? [ - this._renderThumbnail(upThumbnail, false, (item: ViewItem, ev: Event) => - upFolderClickHandler(item, ev, this.viewManagerEpoch), - ), - ] - : []), - ]; + const upFolderItem = getUpFolderItem( + this.viewManagerEpoch?.manager.getView()?.query, + ); + const thumbnails: TemplateResult[] = upFolderItem + ? [ + this._renderThumbnail(upFolderItem, false, (_item: ViewItem, ev: Event) => { + stopEventFromActivatingCardWideActions(ev); + navigateUp(this._getFolderNavOptions()); + }), + ] + : []; const view = this.viewManagerEpoch?.manager.getView(); const selectedIndex = this._getSelectedSlide(); for (const item of view?.queryResults?.getResults() ?? []) { + const clickHandler = (item: ViewItem, ev: Event) => { + stopEventFromActivatingCardWideActions(ev); + if (ViewItemClassifier.isMedia(item)) { + this._handleMediaClick(item); + } else if (ViewItemClassifier.isFolder(item)) { + navigateToFolder(item, this._getFolderNavOptions()); + } + }; thumbnails.push( this._renderThumbnail( item, selectedIndex === thumbnails.length, - (item: ViewItem, ev: Event) => this._itemClickCallback(item, ev), + clickHandler, view?.context?.mediaViewer?.seek, ), ); @@ -214,18 +227,18 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { } protected render(): TemplateResult | void { - const direction = this._getDirection(); - if (!this._thumbnails.length || !this.config || !direction) { + if (!this._thumbnails.length || !this.config?.mode || this.config.mode === 'none') { return; } return html` ${this._thumbnails} - `; + `; } static get styles(): CSSResultGroup { diff --git a/src/components/thumbnail/details.ts b/src/components/thumbnail/details.ts index a729d82e..f8d05b90 100644 --- a/src/components/thumbnail/details.ts +++ b/src/components/thumbnail/details.ts @@ -7,10 +7,12 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; import { CameraManager } from '../../camera-manager/manager'; -import { ThumbnailDetailsController } from '../../components-lib/thumbnail/details-controller'; +import { MediaDetailsController } from '../../components-lib/media/details-controller'; import { HomeAssistant } from '../../ha/types'; import thumbnailDetailsStyle from '../../scss/thumbnail-details.scss'; +import { MetadataField } from '../../types'; import { ViewItem } from '../../view/item'; import '../icon'; @@ -28,7 +30,7 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement { @property({ attribute: false }) public seek?: Date; - private _controller = new ThumbnailDetailsController(); + private _controller = new MediaDetailsController(); protected willUpdate(changedProperties: PropertyValues): void { if (['item', 'seek', 'cameraManager'].some((prop) => changedProperties.has(prop))) { @@ -40,28 +42,26 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement { const heading = this._controller.getHeading(); const details = this._controller.getDetails(); + const renderDetail = (detail: MetadataField, heading = false): TemplateResult => { + return html`
+ ${detail.icon + ? html` ` + : ''} + ${detail.title} +
`; + }; + return html` - ${heading - ? html`
- ${heading} -
` - : ``} - ${details - ? html`
- ${details.map( - (detail) => - html`
- ${detail.icon - ? html` ` - : ''} - ${detail.title} -
`, - )} -
` - : ''} + ${heading ? renderDetail(heading, true) : ``} + ${details.length ? details.map((detail) => renderDetail(detail)) : ``} `; } diff --git a/src/components/thumbnail/feature/feature.ts b/src/components/thumbnail/feature/feature.ts index 023bcf51..c1032a29 100644 --- a/src/components/thumbnail/feature/feature.ts +++ b/src/components/thumbnail/feature/feature.ts @@ -9,10 +9,26 @@ import { import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { CameraManager } from '../../../camera-manager/manager'; +import { ViewItemManager } from '../../../card-controller/view/item-manager'; +import { ViewManagerEpoch } from '../../../card-controller/view/types'; +import { + MediaDetailsController, + OverlayControlsContext, +} from '../../../components-lib/media/details-controller'; import { ThumbnailFeatureController } from '../../../components-lib/thumbnail/feature/controller'; import { HomeAssistant } from '../../../ha/types'; +import { localize } from '../../../localize/localize'; import thumbnailFeatureStyle from '../../../scss/thumbnail-feature.scss'; +import { stopEventFromActivatingCardWideActions } from '../../../utils/action'; +import { + downloadMedia, + navigateToTimeline, + toggleFavorite, + toggleReviewed, +} from '../../../utils/media-actions'; +import { dispatchShowOverlayMessageEvent } from '../../../utils/overlay-message'; import { ViewItem } from '../../../view/item'; +import { ViewItemClassifier } from '../../../view/item-classifier'; import '../../icon.js'; import './thumbnail.js'; @@ -24,12 +40,33 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public viewItemManager?: ViewItemManager; + + @property({ attribute: false }) + public viewManagerEpoch?: ViewManagerEpoch; + @property({ attribute: false }) public item?: ViewItem; @property({ attribute: false }) public hasDetails?: boolean; + @property({ attribute: true, type: Boolean }) + public show_favorite_control = false; + + @property({ attribute: true, type: Boolean }) + public show_timeline_control = false; + + @property({ attribute: true, type: Boolean }) + public show_download_control = false; + + @property({ attribute: true, type: Boolean }) + public show_review_control = false; + + @property({ attribute: true, type: Boolean }) + public show_info_control = false; + private _controller = new ThumbnailFeatureController(); protected willUpdate(changedProperties: PropertyValues): void { @@ -40,34 +77,158 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement { } } + private _getControlContext(): OverlayControlsContext { + return { + hass: this.hass, + viewItemManager: this.viewItemManager, + viewManagerEpoch: this.viewManagerEpoch, + capabilities: this.item ? this.viewItemManager?.getCapabilities(this.item) : null, + }; + } + protected render(): TemplateResult | void { + if (!this.item) { + return; + } + + const starClasses = { + star: true, + starred: ViewItemClassifier.isMedia(this.item) && !!this.item?.isFavorite(), + }; + + const shouldShowTimelineControl = + this.show_timeline_control && ViewItemClassifier.supportsTimeline(this.item); + + const mediaCapabilities = this.viewItemManager?.getCapabilities(this.item) ?? null; + + const shouldShowFavoriteControl = + this.show_favorite_control && + this.item && + this.hass && + mediaCapabilities?.canFavorite; + + const shouldShowDownloadControl = + this.show_download_control && + this.hass && + this.item.getID() && + mediaCapabilities?.canDownload; + + const isReviewed = ViewItemClassifier.isReview(this.item) + ? this.item.isReviewed() + : null; + const shouldShowReviewControl = this.show_review_control && isReviewed !== null; + + const shouldShowInfoControl = + this.show_info_control && ViewItemClassifier.isMedia(this.item); + const title = this._controller.getTitle(); const subtitles = this._controller.getSubtitles(); - const iconClasses = classMap({ - background: title || subtitles.length, - }); + const hasText = !!title || !!subtitles.length; + + const mainIconClasses = { + placeholder: true, + }; const thumbnailClass = this._controller.getThumbnailClass(); const thumbnailClasses = classMap({ ...(thumbnailClass && { [thumbnailClass]: true }), + 'has-text': hasText, }); return html` - ${this._controller.getThumbnail() - ? html` ` - : this._controller.getIcon() - ? html` + ${this._controller.getThumbnail() + ? html` ` + : this._controller.getIcon() + ? html`` + : ''} +
+ ${shouldShowReviewControl + ? html` { + stopEventFromActivatingCardWideActions(ev); + if (this.item) { + await toggleReviewed(this.item, this._getControlContext()); + } + }} + >` + : shouldShowFavoriteControl + ? html` { + stopEventFromActivatingCardWideActions(ev); + if ( + this.item && + (await toggleFavorite(this.item, this._getControlContext())) + ) { + this.requestUpdate(); + } + }} >` - : ''} - ${title || subtitles.length + : ``} + ${shouldShowInfoControl + ? html` { + stopEventFromActivatingCardWideActions(ev); + const detailsController = new MediaDetailsController(); + detailsController.calculate(this.cameraManager, this.item); + dispatchShowOverlayMessageEvent( + this, + detailsController.getMessage(this._getControlContext()), + ); + }} + >` + : ''} + ${shouldShowTimelineControl + ? html` { + stopEventFromActivatingCardWideActions(ev); + if (this.item) { + navigateToTimeline(this.item, this._getControlContext()); + } + }} + >` + : ''} + ${shouldShowDownloadControl + ? html` { + stopEventFromActivatingCardWideActions(ev); + if (this.item) { + await downloadMedia(this.item, this._getControlContext()); + } + }} + >` + : ``} + ${hasText ? html` ${title ? html`
${title}
` : ''} ${subtitles.length diff --git a/src/components/thumbnail/thumbnail.ts b/src/components/thumbnail/thumbnail.ts index e73ba9e4..d454e4e8 100644 --- a/src/components/thumbnail/thumbnail.ts +++ b/src/components/thumbnail/thumbnail.ts @@ -1,18 +1,11 @@ import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; import { CameraManager } from '../../camera-manager/manager.js'; import { FoldersManager } from '../../card-controller/folders/manager.js'; import { ViewItemManager } from '../../card-controller/view/item-manager.js'; -import { RemoveContextViewModifier } from '../../card-controller/view/modifiers/remove-context.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; -import { dispatchAdvancedCameraCardErrorEvent } from '../../components-lib/message/dispatch.js'; import { HomeAssistant } from '../../ha/types.js'; -import { localize } from '../../localize/localize.js'; import thumbnailStyle from '../../scss/thumbnail.scss'; -import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; -import { errorToConsole } from '../../utils/basic.js'; -import { ViewItemClassifier } from '../../view/item-classifier.js'; import { ViewItem } from '../../view/item.js'; import './details.js'; import './feature/feature.js'; @@ -56,6 +49,12 @@ export class AdvancedCameraCardThumbnail extends LitElement { @property({ attribute: true, type: Boolean }) public show_download_control = false; + @property({ attribute: true, type: Boolean }) + public show_review_control = false; + + @property({ attribute: true, type: Boolean }) + public show_info_control = false; + @property({ attribute: false }) public seek?: Date; @@ -68,62 +67,21 @@ export class AdvancedCameraCardThumbnail extends LitElement { return; } - const starClasses = { - star: true, - starred: ViewItemClassifier.isMedia(this.item) && !!this.item?.isFavorite(), - }; - - const shouldShowTimelineControl = - this.show_timeline_control && - ((ViewItemClassifier.isEvent(this.item) && this.item.getStartTime()) || - (ViewItemClassifier.isRecording(this.item) && - this.item.getStartTime() && - this.item.getEndTime())); - - const mediaCapabilities = this.viewItemManager?.getCapabilities(this.item) ?? null; - - const shouldShowFavoriteControl = - this.show_favorite_control && - this.item && - this.hass && - mediaCapabilities?.canFavorite; - - const shouldShowDownloadControl = - this.show_download_control && - this.hass && - this.item.getID() && - mediaCapabilities?.canDownload; - return html` - ${shouldShowFavoriteControl - ? html` { - stopEventFromActivatingCardWideActions(ev); - if (this.hass && this.item) { - try { - await this.viewItemManager?.favorite( - this.item, - !this.item.isFavorite(), - ); - } catch (e) { - errorToConsole(e as Error); - return; - } - this.requestUpdate(); - } - }} - />` - : ``} ${this.details ? html`` : ''} - ${shouldShowTimelineControl - ? html` { - stopEventFromActivatingCardWideActions(ev); - if (!this.viewManagerEpoch || !this.item) { - return; - } - this.viewManagerEpoch.manager.setViewByParameters({ - params: { - view: 'timeline', - queryResults: this.viewManagerEpoch?.manager - .getView() - ?.queryResults?.clone() - .selectResultIfFound((media) => media === this.item), - }, - modifiers: [new RemoveContextViewModifier(['timeline'])], - }); - }} - >` - : ''} - ${shouldShowDownloadControl - ? html` { - stopEventFromActivatingCardWideActions(ev); - if (this.hass && this.item) { - try { - this.viewItemManager?.download(this.item); - } catch (error: unknown) { - dispatchAdvancedCameraCardErrorEvent(this, error); - } - } - }} - >` - : ``} `; } diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 633a8c0b..ef56010a 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -18,7 +18,6 @@ import { ThumbnailDataRequest, ThumbnailDataRequestEvent, TimelineItemClickAction, - TimelineKeys, } from '../components-lib/timeline/types'; import { ConditionStateManagerReadonlyInterface } from '../conditions/types'; import { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails'; @@ -120,11 +119,6 @@ export class AdvancedCameraCardTimelineCore extends LitElement { @property({ attribute: true, type: Boolean, reflect: true }) public mini = false; - // Which cameraIDs to include in the timeline. If not specified, all cameraIDs - // are shown. - @property({ attribute: false, hasChanged: contentsChanged }) - public keys?: TimelineKeys; - @property({ attribute: false }) public cameraManager?: CameraManager; @@ -152,7 +146,22 @@ export class AdvancedCameraCardTimelineCore extends LitElement { return; } - if (!this.keys) { + const view = this.viewManagerEpoch?.manager.getView(); + const isLoading = !!view?.context?.loading?.query; + + if (isLoading) { + if (!this.mini) { + return renderMessage({ + message: localize('error.awaiting_media'), + icon: 'mdi:chart-gantt', + type: 'info', + dotdotdot: true, + }); + } + return; + } + + if (!view?.query || !view.query.hasNodes()) { if (!this.mini) { return renderMessage({ message: localize('error.no_camera_or_media_for_timeline'), @@ -234,13 +243,14 @@ export class AdvancedCameraCardTimelineCore extends LitElement { if ( [ + 'viewManagerEpoch', 'cameraManager', 'viewItemManager', 'timelineConfig', 'mini', 'thumbnailConfig', - 'keys', 'conditionStateManager', + 'foldersManager', ].some((prop) => changedProps.has(prop)) ) { this._controller.setOptions({ @@ -251,7 +261,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement { timelineConfig: this.timelineConfig, mini: this.mini, thumbnailConfig: this.thumbnailConfig, - keys: this.keys, + query: this.viewManagerEpoch?.manager.getView()?.query ?? undefined, }); } } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 6ebcdafd..3bde8942 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -4,13 +4,11 @@ import { CameraManager } from '../camera-manager/manager'; import { FoldersManager } from '../card-controller/folders/manager'; import { ViewItemManager } from '../card-controller/view/item-manager'; import { ViewManagerEpoch } from '../card-controller/view/types'; -import { TimelineKeys } from '../components-lib/timeline/types'; import { ConditionStateManagerReadonlyInterface } from '../conditions/types'; import { TimelineConfig } from '../config/schema/timeline'; import { CardWideConfig } from '../config/schema/types'; import { HomeAssistant } from '../ha/types'; import basicBlockStyle from '../scss/basic-block.scss'; -import { QueryClassifier } from '../view/query-classifier'; import './surround.js'; import './timeline-core.js'; @@ -40,47 +38,6 @@ export class AdvancedCameraCardTimeline extends LitElement { @property({ attribute: false }) public cardWideConfig?: CardWideConfig; - protected _getKeys(): TimelineKeys | undefined { - const query = this.viewManagerEpoch?.manager.getView()?.query; - - // If there's a query, try to extract camera IDs or folder info from it. - if (QueryClassifier.isMediaQuery(query)) { - const cameraIDs = query.getQueryCameraIDs(); - if (cameraIDs && cameraIDs.size) { - return { - type: 'camera', - cameraIDs, - }; - } - } else if (QueryClassifier.isFolderQuery(query)) { - const folderConfig = query.getQuery()?.folder; - if (folderConfig) { - return { - type: 'folder', - folder: folderConfig, - }; - } - } - - // Otherwise fall back to all cameras that support media queries. - const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability({ - anyCapabilities: ['clips', 'snapshots', 'recordings'], - }); - const folder = this.foldersManager?.getFolder() ?? null; - - return cameraIDs?.size - ? { - type: 'camera', - cameraIDs, - } - : folder - ? { - type: 'folder', - folder, - } - : undefined; - } - protected render(): TemplateResult | void { if (!this.timelineConfig) { return html``; @@ -96,7 +53,6 @@ export class AdvancedCameraCardTimeline extends LitElement { .foldersManager=${this.foldersManager} .conditionStateManager=${this.conditionStateManager} .viewItemManager=${this.viewItemManager} - .keys=${this._getKeys()} .cardWideConfig=${this.cardWideConfig} .itemClickAction=${this.timelineConfig.controls.thumbnails.mode === 'none' ? 'play' diff --git a/src/components/video-player.ts b/src/components/video-player.ts index c65dffb2..543caf78 100644 --- a/src/components/video-player.ts +++ b/src/components/video-player.ts @@ -53,7 +53,7 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl ); } }} - @loadeddata=${(ev: Event) => { + @loadeddata="${(ev: Event) => { dispatchMediaLoadedEvent(this, ev, { ...(this._mediaPlayerController && { mediaPlayerController: this._mediaPlayerController, @@ -64,7 +64,7 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl }, technology: ['mp4'], }); - }} + }}" @volumechange=${() => dispatchMediaVolumeChangeEvent(this)} @play=${() => dispatchMediaPlayEvent(this)} @pause=${() => dispatchMediaPauseEvent(this)} diff --git a/src/components/viewer/grid.ts b/src/components/viewer/grid.ts index 7f4ffb47..df0d9978 100644 --- a/src/components/viewer/grid.ts +++ b/src/components/viewer/grid.ts @@ -9,6 +9,7 @@ import { import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { CameraManager } from '../../camera-manager/manager.js'; +import { ViewItemManager } from '../../card-controller/view/item-manager.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { MediaGridSelected } from '../../components-lib/media-grid-controller.js'; import { CardWideConfig } from '../../config/schema/types.js'; @@ -39,6 +40,9 @@ export class AdvancedCameraCardViewerGrid extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public viewItemManager?: ViewItemManager; + protected _renderCarousel(filterCamera?: string): TemplateResult { const selectedCameraID = this.viewManagerEpoch?.manager.getView()?.camera; @@ -60,6 +64,7 @@ export class AdvancedCameraCardViewerGrid extends LitElement { .cameraManager=${this.cameraManager} .cardWideConfig=${this.cardWideConfig} .showControls=${!filterCamera || selectedCameraID === filterCamera} + .viewItemManager=${this.viewItemManager} > `; diff --git a/src/components/viewer/index.ts b/src/components/viewer/index.ts index 40be8371..cc1397ec 100644 --- a/src/components/viewer/index.ts +++ b/src/components/viewer/index.ts @@ -8,6 +8,7 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { CameraManager } from '../../camera-manager/manager.js'; +import { ViewItemManager } from '../../card-controller/view/item-manager.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { CardWideConfig } from '../../config/schema/types.js'; import { ViewerConfig } from '../../config/schema/viewer.js'; @@ -50,6 +51,9 @@ export class AdvancedCameraCardViewer extends LitElement { @property({ attribute: false }) public cardWideConfig?: CardWideConfig; + @property({ attribute: false }) + public viewItemManager?: ViewItemManager; + @property({ attribute: 'empty', reflect: true, type: Boolean }) public isEmpty = false; @@ -96,6 +100,7 @@ export class AdvancedCameraCardViewer extends LitElement { .resolvedMediaCache=${this.resolvedMediaCache} .cameraManager=${this.cameraManager} .cardWideConfig=${this.cardWideConfig} + .viewItemManager=${this.viewItemManager} > `; } diff --git a/src/components/viewer/provider.ts b/src/components/viewer/provider.ts index 3f8b7b99..40249d53 100644 --- a/src/components/viewer/provider.ts +++ b/src/components/viewer/provider.ts @@ -10,6 +10,7 @@ import { customElement, property, state } from 'lit/decorators.js'; import { guard } from 'lit/directives/guard.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../../camera-manager/manager.js'; +import { QueryType } from '../../camera-manager/types.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { LazyLoadController } from '../../components-lib/lazy-load-controller.js'; import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js'; @@ -29,7 +30,7 @@ import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../ty import { errorToConsole } from '../../utils/basic.js'; import { ViewItemClassifier } from '../../view/item-classifier.js'; import { VideoContentType, ViewMedia } from '../../view/item.js'; -import { QueryClassifier } from '../../view/query-classifier.js'; +import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js'; import '../image-player.js'; import { renderProgressIndicator } from '../progress-indicator.js'; import '../video-player.js'; @@ -85,19 +86,13 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi // If this specific media item has no clip, then do nothing (even if all // the other media items do). !ViewItemClassifier.isEvent(this.media) || - !QueryClassifier.isEventQuery(view.query) + !view.query?.hasMediaQueriesOfType(QueryType.Event) ) { return; } // Convert the query to a clips equivalent. - const clipQuery = view.query.clone(); - clipQuery.convertToClipsQueries(); - - const queries = clipQuery.getQuery(); - if (!queries) { - return; - } + const clipQuery = UnifiedQueryTransformer.convertToClips(view.query); await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ params: { diff --git a/src/components/views.ts b/src/components/views.ts index c7506d77..9385f7dc 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -76,16 +76,14 @@ export class AdvancedCameraCardViews extends LitElement { if (view?.is('live') || this._shouldLivePreload()) { import('./live/index.js'); } - if (view?.isMediaGalleryView() && !view.is('folders')) { - import('./gallery/media-gallery.js'); + if (view?.isGalleryView()) { + import('./gallery/gallery.js'); } else if (view?.isViewerView()) { import('./viewer/index.js'); } else if (view?.is('image')) { import('./image.js'); } else if (view?.is('timeline')) { import('./timeline.js'); - } else if (view?.is('folders')) { - import('./gallery/folder-gallery.js'); } } @@ -167,16 +165,18 @@ export class AdvancedCameraCardViews extends LitElement { > ` : ``} - ${!this.hide && view?.isMediaGalleryView() && !view.is('folders') - ? html` - ` + ` : ``} ${!this.hide && view?.isViewerView() ? html` @@ -187,6 +187,7 @@ export class AdvancedCameraCardViews extends LitElement { .resolvedMediaCache=${this.resolvedMediaCache} .cameraManager=${this.cameraManager} .cardWideConfig=${this.cardWideConfig} + .viewItemManager=${this.viewItemManager} > ` @@ -212,15 +213,6 @@ export class AdvancedCameraCardViews extends LitElement { > ` : ``} - ${!this.hide && view?.is('folders') - ? html` ` - : ``} ${ // Note: Subtle difference in condition below vs the other views in // order to always render the live view for live.preload mode. diff --git a/src/config/management.ts b/src/config/management.ts index 45a30418..02d9bfaa 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -6,13 +6,9 @@ import { CONF_CAMERAS_GLOBAL_PTZ, CONF_DIMENSIONS_HEIGHT, CONF_ELEMENTS, - CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE, - CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE, CONF_OVERRIDES, CONF_PROFILES, CONF_STATUS_BAR, - CONF_TIMELINE_EVENTS_MEDIA_TYPE, CONF_VIEW_DEFAULT_CYCLE_CAMERA, CONF_VIEW_DEFAULT_RESET_ENTITIES, CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS, @@ -825,18 +821,19 @@ const UPGRADES = [ upgradeWithOverrides('media_viewer.auto_unmute', (data) => data === 'all' ? ['selected', 'visible'] : data === 'never' ? null : arrayify(data), ), + upgradeMoveToWithOverrides( 'live.controls.thumbnails.media', - CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE, + 'live.controls.thumbnails.events_media_type', ), - upgradeMoveToWithOverrides('timeline.media', CONF_TIMELINE_EVENTS_MEDIA_TYPE), + upgradeMoveToWithOverrides('timeline.media', 'timeline.events_media_type'), upgradeMoveToWithOverrides( 'live.controls.timeline.media', - CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE, + 'live.controls.timeline.events_media_type', ), upgradeMoveToWithOverrides( 'media_viewer.controls.timeline.media', - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE, + 'media_viewer.controls.timeline.events_media_type', ), upgradeMoveToWithOverrides('view.scan', CONF_VIEW_TRIGGERS), upgradeMoveToWithOverrides( @@ -944,4 +941,23 @@ const UPGRADES = [ frigateCardToAdvancedCameraCardStyleTransform, ), upgradeMoveToWithOverrides('menu.buttons.frigate', 'menu.buttons.iris'), + + // v8.0.0+ + upgradeMoveToWithOverrides( + 'live.controls.thumbnails.media_type', + 'cameras_global.media.type', + ), + upgradeMoveToWithOverrides( + 'live.controls.thumbnails.events_media_type', + 'cameras_global.media.events_type', + { + transform: (val) => { + // 'all' is the default, delete it + if (val === 'all') { + return null; + } + return val; + }, + }, + ), ]; diff --git a/src/config/schema/actions/custom/general.ts b/src/config/schema/actions/custom/general.ts index 1ed41d86..da61a915 100644 --- a/src/config/schema/actions/custom/general.ts +++ b/src/config/schema/actions/custom/general.ts @@ -7,6 +7,7 @@ const GENERAL_ACTIONS = [ 'download', 'expand', 'fullscreen', + 'info', 'live_substream_off', 'live_substream_on', 'menu_toggle', diff --git a/src/config/schema/actions/custom/set-review.ts b/src/config/schema/actions/custom/set-review.ts new file mode 100644 index 00000000..214c7ad9 --- /dev/null +++ b/src/config/schema/actions/custom/set-review.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import { advancedCameraCardCustomActionsBaseSchema } from './base'; + +export const setReviewActionConfigSchema = + advancedCameraCardCustomActionsBaseSchema.extend({ + advanced_camera_card_action: z.literal('set_review'), + + // Mark a review as reviewed or unreviewed. + // - true = mark reviewed + // - false = mark unreviewed + // - undefined = toggle reviewed status + reviewed: z.boolean().optional(), + }); +export type SetReviewActionConfig = z.infer; diff --git a/src/config/schema/actions/types.ts b/src/config/schema/actions/types.ts index d8665e60..00cdcd64 100644 --- a/src/config/schema/actions/types.ts +++ b/src/config/schema/actions/types.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { statusBarItemBaseSchema } from '../common/status-bar'; +import { severitySchema } from '../common/severity'; import { advancedCameraCardCustomActionsBaseSchema } from './custom/base'; import { cameraSelectActionConfigSchema } from './custom/camera-select'; import { viewDisplayModeActionConfigSchema } from './custom/display-mode'; @@ -12,6 +13,7 @@ import { ptzActionConfigSchema } from './custom/ptz'; import { ptzControlsActionConfigSchema } from './custom/ptz-controls'; import { ptzDigitalActionConfigSchema } from './custom/ptz-digital'; import { ptzMultiActionSchema } from './custom/ptz-multi'; +import { setReviewActionConfigSchema } from './custom/set-review'; import { sleepActionConfigSchema } from './custom/sleep'; import { substreamSelectActionConfigSchema } from './custom/substream-select'; import { viewActionConfigSchema } from './custom/view'; @@ -51,6 +53,7 @@ const advancedCameraCardCustomActionSchema = z.union([ ptzControlsActionConfigSchema, ptzDigitalActionConfigSchema, ptzMultiActionSchema, + setReviewActionConfigSchema, sleepActionConfigSchema, statusBarActionConfigSchema, substreamSelectActionConfigSchema, @@ -106,6 +109,7 @@ const statusBarItemElementsBaseSchema = statusBarItemBaseSchema.extend({ sufficient: z.boolean().default(false).optional(), exclusive: z.boolean().default(false).optional(), expand: z.boolean().default(false).optional(), + severity: severitySchema.optional(), actions: actionsBaseSchema.optional(), }); diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index f5624159..0943cca8 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -3,6 +3,8 @@ import { capabilityKeys } from '../../types'; import { mediaLayoutConfigSchema } from './camera/media-layout'; import { ptzCameraConfigDefaults, ptzCameraConfigSchema } from './camera/ptz'; import { aspectRatioSchema } from './common/aspect-ratio'; +import { eventsMediaTypeSchema } from './common/events-media'; +import { severitySchema } from './common/severity'; import { imageBaseConfigSchema, imageConfigDefault } from './common/image'; const CAMERA_TRIGGER_EVENT_TYPES = [ @@ -124,8 +126,12 @@ export const cameraConfigDefault = { triggers: { motion: false, occupancy: false, - events: [...CAMERA_TRIGGER_EVENT_TYPES], + events: [], entities: [], + reviews: { + severities: ['high' as const], + description: true, + }, }, proxy: { dynamic: true, @@ -173,6 +179,26 @@ const cameraDimensionsSchema = z.object({ }); export type CameraDimensionsConfig = z.infer; +// Camera media configuration for default media type in live/timeline views. +const CAMERA_MEDIA_TYPES = [ + 'auto', + 'reviews', + 'events', + 'recordings', + 'folder', +] as const; +export type CameraMediaType = (typeof CAMERA_MEDIA_TYPES)[number]; + +const cameraMediaConfigDefault = { + type: 'auto' as CameraMediaType, +}; + +const cameraMediaConfigSchema = z.object({ + type: z.enum(CAMERA_MEDIA_TYPES).default(cameraMediaConfigDefault.type), + events_type: eventsMediaTypeSchema.optional(), + folders: z.array(z.string()).optional(), +}); + export const cameraConfigSchema = z .object({ camera_entity: z.string().optional(), @@ -209,6 +235,16 @@ export const cameraConfigSchema = z .enum(CAMERA_TRIGGER_EVENT_TYPES) .array() .default(cameraConfigDefault.triggers.events), + reviews: z + .object({ + severities: severitySchema + .array() + .default([...cameraConfigDefault.triggers.reviews.severities]), + description: z + .boolean() + .default(cameraConfigDefault.triggers.reviews.description), + }) + .default(cameraConfigDefault.triggers.reviews), }) .default(cameraConfigDefault.triggers), @@ -274,6 +310,8 @@ export const cameraConfigSchema = z dimensions: cameraDimensionsSchema.optional(), + media: cameraMediaConfigSchema.optional(), + proxy: proxyConfigSchema.default(cameraConfigDefault.proxy), // See: https://github.com/dermotduffy/advanced-camera-card/issues/1650 diff --git a/src/config/schema/common/const.ts b/src/config/schema/common/const.ts index 19de9e24..572bd437 100644 --- a/src/config/schema/common/const.ts +++ b/src/config/schema/common/const.ts @@ -22,6 +22,8 @@ export const VIEWS_USER_SPECIFIED = [ 'snapshots', 'recording', 'recordings', + 'review', + 'reviews', 'image', 'timeline', ] as const; diff --git a/src/config/schema/common/controls/thumbnails.ts b/src/config/schema/common/controls/thumbnails.ts index ef68451e..cc71e73f 100644 --- a/src/config/schema/common/controls/thumbnails.ts +++ b/src/config/schema/common/controls/thumbnails.ts @@ -9,8 +9,10 @@ const thumbnailControlsBaseDefaults = { size: THUMBNAIL_WIDTH_DEFAULT, show_details: true, show_favorite_control: true, - show_timeline_control: true, - show_download_control: true, + show_timeline_control: false, + show_download_control: false, + show_review_control: true, + show_info_control: true, }; // Configuration for the actual rendered thumbnail. @@ -30,6 +32,12 @@ export const thumbnailsControlBaseSchema = z.object({ show_download_control: z .boolean() .default(thumbnailControlsBaseDefaults.show_download_control), + show_review_control: z + .boolean() + .default(thumbnailControlsBaseDefaults.show_review_control), + show_info_control: z + .boolean() + .default(thumbnailControlsBaseDefaults.show_info_control), }); export type ThumbnailsControlBaseConfig = z.infer; diff --git a/src/config/schema/common/controls/timeline.ts b/src/config/schema/common/controls/timeline.ts index 1d550867..d7377991 100644 --- a/src/config/schema/common/controls/timeline.ts +++ b/src/config/schema/common/controls/timeline.ts @@ -1,10 +1,7 @@ import { z } from 'zod'; -import { eventsMediaTypeSchema } from '../events-media'; -import { thumbnailControlsDefaults } from './thumbnails'; -const timelineCoreConfigDefault = { +export const timelineCoreConfigDefault = { clustering_threshold: 3, - events_media_type: 'all' as const, window_seconds: 60 * 60, show_recordings: true, style: 'stack' as const, @@ -26,9 +23,6 @@ export const timelineCoreConfigSchema = z.object({ .number() .optional() .default(timelineCoreConfigDefault.clustering_threshold), - events_media_type: eventsMediaTypeSchema - .optional() - .default(timelineCoreConfigDefault.events_media_type), window_seconds: z .number() .min(1 * 60) @@ -58,10 +52,3 @@ export const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({ style: timelineCoreConfigSchema.shape.style.default(miniTimelineConfigDefault.style), }); export type MiniTimelineControlConfig = z.infer; - -export const timelineConfigDefault = { - ...timelineCoreConfigDefault, - controls: { - thumbnails: thumbnailControlsDefaults, - }, -}; diff --git a/src/config/schema/common/severity.ts b/src/config/schema/common/severity.ts new file mode 100644 index 00000000..4028ae50 --- /dev/null +++ b/src/config/schema/common/severity.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; +import { SEVERITIES } from '../../../types'; + +export const severitySchema = z.enum(SEVERITIES); diff --git a/src/config/schema/elements/custom/menu/base.ts b/src/config/schema/elements/custom/menu/base.ts index c03ca165..f9b8b929 100644 --- a/src/config/schema/elements/custom/menu/base.ts +++ b/src/config/schema/elements/custom/menu/base.ts @@ -13,3 +13,4 @@ export const menuBaseSchema = z.object({ icon: z.string().optional(), permanent: z.boolean().default(false).optional(), }); +export type MenuItemBase = z.infer; diff --git a/src/config/schema/live.ts b/src/config/schema/live.ts index 2da1256f..49fabe34 100644 --- a/src/config/schema/live.ts +++ b/src/config/schema/live.ts @@ -11,7 +11,6 @@ import { miniTimelineConfigSchema, } from './common/controls/timeline'; import { viewDisplaySchema } from './common/display'; -import { eventsMediaTypeSchema } from './common/events-media'; import { MEDIA_ACTION_NEGATIVE_CONDITIONS, MEDIA_ACTION_POSITIVE_CONDITIONS, @@ -41,12 +40,6 @@ const microphoneConfigSchema = z .default(microphoneConfigDefault); export type MicrophoneConfig = z.infer; -const liveThumbnailControlsDefaults = { - ...thumbnailControlsDefaults, - media_type: 'events' as const, - events_media_type: 'all' as const, -}; - export const liveConfigDefault = { auto_play: [...MEDIA_ACTION_POSITIVE_CONDITIONS], auto_pause: [], @@ -67,7 +60,7 @@ export const liveConfigDefault = { style: 'chevrons' as const, }, ptz: ptzControlsDefaults, - thumbnails: liveThumbnailControlsDefaults, + thumbnails: thumbnailControlsDefaults, timeline: miniTimelineConfigDefault, wheel: true, }, @@ -76,15 +69,6 @@ export const liveConfigDefault = { }, }; -const livethumbnailsControlSchema = thumbnailsControlSchema.extend({ - media_type: z - .enum(['events', 'recordings']) - .default(liveConfigDefault.controls.thumbnails.media_type), - events_media_type: eventsMediaTypeSchema.default( - liveConfigDefault.controls.thumbnails.events_media_type, - ), -}); - export const liveConfigSchema = z .object({ auto_pause: z @@ -118,7 +102,7 @@ export const liveConfigSchema = z }) .default(liveConfigDefault.controls.next_previous), ptz: ptzControlsConfigSchema.default(liveConfigDefault.controls.ptz), - thumbnails: livethumbnailsControlSchema.default( + thumbnails: thumbnailsControlSchema.default( liveConfigDefault.controls.thumbnails, ), timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline), diff --git a/src/config/schema/menu.ts b/src/config/schema/menu.ts index 6b91a0de..232b0609 100644 --- a/src/config/schema/menu.ts +++ b/src/config/schema/menu.ts @@ -29,7 +29,7 @@ export const menuConfigDefault = { buttons: { camera_ui: visibleButtonDefault, cameras: visibleButtonDefault, - clips: visibleButtonDefault, + clips: hiddenButtonDefault, ptz_home: hiddenButtonDefault, display_mode: visibleButtonDefault, download: visibleButtonDefault, @@ -38,6 +38,7 @@ export const menuConfigDefault = { iris: visibleButtonDefault, fullscreen: visibleButtonDefault, image: hiddenButtonDefault, + info: visibleButtonDefault, live: visibleButtonDefault, media_player: visibleButtonDefault, microphone: { @@ -48,8 +49,10 @@ export const menuConfigDefault = { play: hiddenButtonDefault, ptz_controls: hiddenButtonDefault, recordings: hiddenButtonDefault, + reviews: visibleButtonDefault, + set_review: visibleButtonDefault, screenshot: hiddenButtonDefault, - snapshots: visibleButtonDefault, + snapshots: hiddenButtonDefault, substreams: visibleButtonDefault, timeline: visibleButtonDefault, }, @@ -76,7 +79,7 @@ export const menuConfigSchema = z .object({ camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui), cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras), - clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips), + clips: hiddenButtonSchema.default(menuConfigDefault.buttons.clips), ptz_home: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_home), display_mode: visibleButtonSchema.default( menuConfigDefault.buttons.display_mode, @@ -87,6 +90,7 @@ export const menuConfigSchema = z iris: visibleButtonSchema.default(menuConfigDefault.buttons.iris), fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen), image: hiddenButtonSchema.default(menuConfigDefault.buttons.image), + info: visibleButtonSchema.default(menuConfigDefault.buttons.info), live: visibleButtonSchema.default(menuConfigDefault.buttons.live), media_player: visibleButtonSchema.default( menuConfigDefault.buttons.media_player, @@ -102,8 +106,10 @@ export const menuConfigSchema = z play: hiddenButtonSchema.default(menuConfigDefault.buttons.play), ptz_controls: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_controls), recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings), + reviews: visibleButtonSchema.default(menuConfigDefault.buttons.reviews), + set_review: visibleButtonSchema.default(menuConfigDefault.buttons.set_review), screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot), - snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots), + snapshots: hiddenButtonSchema.default(menuConfigDefault.buttons.snapshots), substreams: visibleButtonSchema.default(menuConfigDefault.buttons.substreams), timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline), }) diff --git a/src/config/schema/status-bar.ts b/src/config/schema/status-bar.ts index 73ad15ca..c3440d79 100644 --- a/src/config/schema/status-bar.ts +++ b/src/config/schema/status-bar.ts @@ -23,6 +23,7 @@ export const statusBarConfigDefault = { items: { engine: statusBarItemDefault, resolution: statusBarItemDefault, + severity: statusBarItemDefault, technology: statusBarItemDefault, title: statusBarItemDefault, }, @@ -50,6 +51,7 @@ export const statusBarConfigSchema = z resolution: statusBarItemBaseSchema.default( statusBarConfigDefault.items.resolution, ), + severity: statusBarItemBaseSchema.default(statusBarConfigDefault.items.severity), title: statusBarItemBaseSchema.default(statusBarConfigDefault.items.title), }) .default(statusBarConfigDefault.items), diff --git a/src/config/schema/timeline.ts b/src/config/schema/timeline.ts index e4c06637..0c881486 100644 --- a/src/config/schema/timeline.ts +++ b/src/config/schema/timeline.ts @@ -1,17 +1,25 @@ import { z } from 'zod'; -import { thumbnailsControlSchema } from './common/controls/thumbnails'; import { - timelineConfigDefault, + thumbnailControlsDefaults, + thumbnailsControlSchema, +} from './common/controls/thumbnails'; +import { + timelineCoreConfigDefault, timelineCoreConfigSchema, } from './common/controls/timeline'; +export const timelineConfigDefault = { + ...timelineCoreConfigDefault, + controls: { + thumbnails: thumbnailControlsDefaults, + }, +}; + export const timelineConfigSchema = timelineCoreConfigSchema .extend({ controls: z .object({ - thumbnails: thumbnailsControlSchema.default( - timelineConfigDefault.controls.thumbnails, - ), + thumbnails: thumbnailsControlSchema.default(thumbnailControlsDefaults), }) .default(timelineConfigDefault.controls), }) diff --git a/src/config/schema/types.ts b/src/config/schema/types.ts index 0650dde3..0c50a218 100644 --- a/src/config/schema/types.ts +++ b/src/config/schema/types.ts @@ -3,7 +3,6 @@ import { deepRemoveDefaults } from '../../utils/zod'; import { automationsSchema } from './automations'; import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './cameras'; import { cardIDRegex } from './common/const'; -import { timelineConfigDefault } from './common/controls/timeline'; import { imageConfigDefault } from './common/image'; import { DebugConfig, debugConfigDefault, debugConfigSchema } from './debug'; import { dimensionsConfigSchema } from './dimensions'; @@ -22,7 +21,7 @@ import { import { profilesSchema } from './profiles'; import { remoteControlConfigDefault, remoteControlConfigSchema } from './remote-control'; import { statusBarConfigDefault, statusBarConfigSchema } from './status-bar'; -import { timelineConfigSchema } from './timeline'; +import { timelineConfigDefault, timelineConfigSchema } from './timeline'; import { viewConfigDefault, viewConfigSchema } from './view'; import { viewerConfigDefault, viewerConfigSchema } from './viewer'; diff --git a/src/const.ts b/src/const.ts index e723ea18..98785563 100644 --- a/src/const.ts +++ b/src/const.ts @@ -109,6 +109,15 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES = `${CONF_CAMERAS}.#.triggers.entities` as const; export const CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS = `${CONF_CAMERAS}.#.triggers.events` as const; +export const CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_SEVERITIES = + `${CONF_CAMERAS}.#.triggers.reviews.severities` as const; +export const CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION = + `${CONF_CAMERAS}.#.triggers.reviews.description` as const; +export const CONF_CAMERAS_ARRAY_MEDIA_TYPE = `${CONF_CAMERAS}.#.media.type` as const; +export const CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE = + `${CONF_CAMERAS}.#.media.events_type` as const; +export const CONF_CAMERAS_ARRAY_MEDIA_FOLDERS = + `${CONF_CAMERAS}.#.media.folders` as const; const CONF_CAMERAS_GLOBAL = 'cameras_global' as const; export const CONF_CAMERAS_GLOBAL_LIVE_PROVIDER = @@ -190,6 +199,10 @@ export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_favorite_control` as const; export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_timeline_control` as const; +export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL = + `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_review_control` as const; +export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL = + `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_info_control` as const; export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE = `${CONF_MEDIA_GALLERY}.controls.thumbnails.size` as const; @@ -230,12 +243,15 @@ export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_favorite_control` as const; export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_timeline_control` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL = + `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_review_control` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL = + `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_info_control` as const; export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE = `${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const; export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD = `${CONF_MEDIA_VIEWER}.controls.timeline.clustering_threshold` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE = - `${CONF_MEDIA_VIEWER}.controls.timeline.events_media_type` as const; + export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H = `${CONF_MEDIA_VIEWER}.controls.timeline.format.24h` as const; export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE = @@ -274,10 +290,7 @@ export const CONF_LIVE_CONTROLS_PTZ_ORIENTATION = export const CONF_LIVE_CONTROLS_PTZ_POSITION = `${CONF_LIVE}.controls.ptz.position` as const; export const CONF_LIVE_CONTROLS_WHEEL = `${CONF_LIVE}.controls.wheel` as const; -export const CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE = - `${CONF_LIVE}.controls.thumbnails.media_type` as const; -export const CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE = - `${CONF_LIVE}.controls.thumbnails.events_media_type` as const; + export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE = `${CONF_LIVE}.controls.thumbnails.mode` as const; export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE = @@ -290,10 +303,12 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = `${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const; export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = `${CONF_LIVE}.controls.thumbnails.show_timeline_control` as const; +export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL = + `${CONF_LIVE}.controls.thumbnails.show_review_control` as const; +export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL = + `${CONF_LIVE}.controls.thumbnails.show_info_control` as const; export const CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD = `${CONF_LIVE}.controls.timeline.clustering_threshold` as const; -export const CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE = - `${CONF_LIVE}.controls.timeline.events_media_type` as const; export const CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H = `${CONF_LIVE}.controls.timeline.format.24h` as const; export const CONF_LIVE_CONTROLS_TIMELINE_MODE = @@ -341,8 +356,6 @@ const CONF_TIMELINE = 'timeline' as const; export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const; export const CONF_TIMELINE_CLUSTERING_THRESHOLD = `${CONF_TIMELINE}.clustering_threshold` as const; -export const CONF_TIMELINE_EVENTS_MEDIA_TYPE = - `${CONF_TIMELINE}.events_media_type` as const; export const CONF_TIMELINE_FORMAT_24H = `${CONF_TIMELINE}.format.24h` as const; export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const; export const CONF_TIMELINE_STYLE = `${CONF_TIMELINE}.style` as const; @@ -358,6 +371,10 @@ export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = `${CONF_TIMELINE}.controls.thumbnails.show_favorite_control` as const; export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = `${CONF_TIMELINE}.controls.thumbnails.show_timeline_control` as const; +export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL = + `${CONF_TIMELINE}.controls.thumbnails.show_review_control` as const; +export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL = + `${CONF_TIMELINE}.controls.thumbnails.show_info_control` as const; const CONF_MENU = 'menu' as const; export const CONF_MENU_ALIGNMENT = `${CONF_MENU}.alignment` as const; diff --git a/src/editor.ts b/src/editor.ts index f8b526a0..96fc38ea 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -71,6 +71,9 @@ import { CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS, CONF_CAMERAS_ARRAY_IMAGE_URL, CONF_CAMERAS_ARRAY_LIVE_PROVIDER, + CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE, + CONF_CAMERAS_ARRAY_MEDIA_FOLDERS, + CONF_CAMERAS_ARRAY_MEDIA_TYPE, CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN, CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN, CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN, @@ -88,6 +91,8 @@ import { CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS, CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY, + CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION, + CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_SEVERITIES, CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY, CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, CONF_DIMENSIONS_ASPECT_RATIO, @@ -117,16 +122,15 @@ import { CONF_LIVE_CONTROLS_PTZ_MODE, CONF_LIVE_CONTROLS_PTZ_ORIENTATION, CONF_LIVE_CONTROLS_PTZ_POSITION, - CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE, - CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE, CONF_LIVE_CONTROLS_THUMBNAILS_MODE, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, - CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE, CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H, CONF_LIVE_CONTROLS_TIMELINE_MODE, CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE, @@ -153,6 +157,8 @@ import { CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE, CONF_MEDIA_VIEWER_AUTO_MUTE, @@ -166,10 +172,11 @@ import { CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE, @@ -212,9 +219,10 @@ import { CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, + CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE, - CONF_TIMELINE_EVENTS_MEDIA_TYPE, CONF_TIMELINE_FORMAT_24H, CONF_TIMELINE_SHOW_RECORDINGS, CONF_TIMELINE_STYLE, @@ -259,6 +267,7 @@ import { localize } from './localize/localize.js'; import editorStyle from './scss/editor.scss'; import { arrayMove, prettifyTitle } from './utils/basic.js'; import { getCameraID } from './utils/camera.js'; +import { getFolderID } from './utils/folder.js'; const MENU_CAMERAS = 'cameras'; const MENU_CAMERAS_CAPABILITIES = 'cameras.capabilities'; @@ -275,7 +284,9 @@ const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye'; const MENU_CAMERAS_PROXY = 'cameras.proxy'; const MENU_CAMERAS_REOLINK = 'cameras.reolink'; const MENU_CAMERAS_TRIGGERS = 'cameras.triggers'; +const MENU_CAMERAS_TRIGGERS_REVIEWS = 'cameras.triggers.reviews'; const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card'; +const MENU_CAMERAS_MEDIA = 'cameras.media'; const MENU_FOLDERS = 'folders'; const MENU_FOLDERS_HA = 'folders.ha'; const MENU_LIVE_CONTROLS = 'live.controls'; @@ -424,6 +435,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard { value: 'live', label: localize('config.view.views.live') }, { value: 'recording', label: localize('config.view.views.recording') }, { value: 'recordings', label: localize('config.view.views.recordings') }, + { value: 'review', label: localize('config.view.views.review') }, + { value: 'reviews', label: localize('config.view.views.reviews') }, { value: 'snapshot', label: localize('config.view.views.snapshot') }, { value: 'snapshots', label: localize('config.view.views.snapshots') }, { value: 'timeline', label: localize('config.view.views.timeline') }, @@ -535,25 +548,53 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard protected _thumbnailMediaTypes: EditorSelectOption[] = [ { value: '', label: '' }, + { + value: 'auto', + label: localize('config.common.media_types.auto'), + }, { value: 'events', - label: localize('config.common.controls.thumbnails.media_types.events'), + label: localize('config.common.media_types.events'), }, { value: 'recordings', - label: localize('config.common.controls.thumbnails.media_types.recordings'), + label: localize('config.common.media_types.recordings'), + }, + { + value: 'reviews', + label: localize('config.common.media_types.reviews'), + }, + ]; + + protected _timelineThumbnailMediaTypes: EditorSelectOption[] = [ + { value: '', label: '' }, + { + value: 'auto', + label: localize('config.common.media_types.auto'), + }, + { + value: 'events', + label: localize('config.common.media_types.events'), + }, + { + value: 'reviews', + label: localize('config.common.media_types.reviews'), }, ]; protected _thumbnailEventsMediaTypes: EditorSelectOption[] = [ { value: '', label: '' }, + { + value: 'all', + label: localize('config.common.events_media_types.all'), + }, { value: 'clips', - label: localize('config.common.controls.thumbnails.events_media_types.clips'), + label: localize('config.common.events_media_types.clips'), }, { value: 'snapshots', - label: localize('config.common.controls.thumbnails.events_media_types.snapshots'), + label: localize('config.common.events_media_types.snapshots'), }, ]; @@ -573,14 +614,14 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard protected _timelineEventsMediaTypes: EditorSelectOption[] = [ { value: '', label: '' }, - { value: 'all', label: localize('config.common.timeline.events_media_types.all') }, + { value: 'all', label: localize('config.common.events_media_types.all') }, { value: 'clips', - label: localize('config.common.timeline.events_media_types.clips'), + label: localize('config.common.events_media_types.clips'), }, { value: 'snapshots', - label: localize('config.common.timeline.events_media_types.snapshots'), + label: localize('config.common.events_media_types.snapshots'), }, ]; @@ -693,6 +734,15 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard { value: 'dashboard', label: localize('config.cameras.cast.methods.dashboard') }, ]; + protected _cameraMediaTypes: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'auto', label: localize('config.common.media_types.auto') }, + { value: 'events', label: localize('config.common.media_types.events') }, + { value: 'recordings', label: localize('config.common.media_types.recordings') }, + { value: 'reviews', label: localize('config.common.media_types.reviews') }, + { value: 'folder', label: localize('config.common.media_types.folder') }, + ]; + protected _ptzModes: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'on', label: localize('config.live.controls.ptz.modes.on') }, @@ -795,6 +845,22 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard }, ]; + protected _severities: EditorSelectOption[] = [ + { value: '', label: '' }, + { + value: 'high', + label: localize('common.severities.high'), + }, + { + value: 'medium', + label: localize('common.severities.medium'), + }, + { + value: 'low', + label: localize('common.severities.low'), + }, + ]; + protected _timelinePanModes: EditorSelectOption[] = [ { value: '', label: '' }, { @@ -1607,7 +1673,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard * @param configPathStyle Timeline style config path. * @param configPathWindowSeconds Timeline window config path. * @param configPathClusteringThreshold Clustering threshold config path. - * @param configPathTimelineEventsMediaType Timeline media config path. * @param configPathShowRecordings Show recordings config path. * @param defaultShowRecordings Default value of show_recordings. * @returns A rendered template. @@ -1617,7 +1682,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard configPathStyle: string, configPathWindowSeconds: string, configPathClusteringThreshold: string, - configPathTimelineEventsMediaType: string, configPathShowRecordings: string, configPathFormat24h: string, defaultShowRecordings: boolean, @@ -1639,13 +1703,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard ${this._renderNumberInput(configPathClusteringThreshold, { label: localize(`config.common.${CONF_TIMELINE_CLUSTERING_THRESHOLD}`), })} - ${this._renderOptionSelector( - configPathTimelineEventsMediaType, - this._timelineEventsMediaTypes, - { - label: localize(`config.common.${CONF_TIMELINE_EVENTS_MEDIA_TYPE}`), - }, - )} ${this._renderSwitch(configPathShowRecordings, defaultShowRecordings, { label: localize(`config.common.${CONF_TIMELINE_SHOW_RECORDINGS}`), })} @@ -1668,7 +1725,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard * @param domain The submenu domain. * @param configPathWindowSeconds Timeline window config path. * @param configPathClusteringThreshold Clustering threshold config path. - * @param configPathTimelineEventsMediaType Timeline media config path. + * @param configPathShowRecordings Show recordings config path. * @returns A rendered template. */ @@ -1678,7 +1735,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard configPathStyle: string, configPathWindowSeconds: string, configPathClusteringThreshold: string, - configPathTimelineEventsMediaType: string, + configPathShowRecordings: string, configPathFormat24h: string, defaultShowRecordings: boolean, @@ -1698,7 +1755,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard configPathStyle, configPathWindowSeconds, configPathClusteringThreshold, - configPathTimelineEventsMediaType, configPathShowRecordings, configPathFormat24h, defaultShowRecordings, @@ -1814,15 +1870,17 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard configPathShowFavoriteControl: string, configPathShowTimelineControl: string, configPathShowDownloadControl: string, + configPathShowReviewControl: string, + configPathShowInfoControl: string, defaults: { show_details: boolean; show_favorite_control: boolean; show_timeline_control: boolean; show_download_control: boolean; + show_review_control: boolean; + show_info_control: boolean; }, options?: { - configPathMediaType?: string; - configPathEventsMediaType?: string; configPathMode?: string; }, ): TemplateResult | void { @@ -1841,24 +1899,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard }, )}` : html``} - ${options?.configPathMediaType - ? html`${this._renderOptionSelector( - options.configPathMediaType, - this._thumbnailMediaTypes, - { - label: localize('config.common.controls.thumbnails.media_type'), - }, - )}` - : html``} - ${options?.configPathEventsMediaType - ? html`${this._renderOptionSelector( - options.configPathEventsMediaType, - this._thumbnailEventsMediaTypes, - { - label: localize('config.common.controls.thumbnails.events_media_type'), - }, - )}` - : html``} ${this._renderNumberInput(configPathSize, { min: THUMBNAIL_WIDTH_MIN, max: THUMBNAIL_WIDTH_MAX, @@ -1888,6 +1928,12 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard label: localize('config.common.controls.thumbnails.show_download_control'), }, )} + ${this._renderSwitch(configPathShowReviewControl, defaults.show_review_control, { + label: localize('config.common.controls.thumbnails.show_review_control'), + })} + ${this._renderSwitch(configPathShowInfoControl, defaults.show_info_control, { + label: localize('config.common.controls.thumbnails.show_info_control'), + })} `, ); } @@ -2125,6 +2171,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard cameras: RawAdvancedCameraCardConfigArray, cameraIndex: number, entities: string[], + folders: RawAdvancedCameraCardConfigArray, addNewCamera?: boolean, ): TemplateResult | void { const liveProviders: EditorSelectOption[] = [ @@ -2159,6 +2206,14 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard } }); + const folderOptions: EditorSelectOption[] = []; + folders.forEach((folder, index) => { + folderOptions.push({ + value: getFolderID(folder, index), + label: this._getEditorFolderTitle(index, folder), + }); + }); + const submenuClasses = { submenu: true, selected: this._expandedMenus[MENU_CAMERAS] === cameraIndex, @@ -2441,6 +2496,65 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard label: localize('config.cameras.triggers.events.editor_label'), }, )} + ${this._putInSubmenu( + MENU_CAMERAS_TRIGGERS_REVIEWS, + cameraIndex, + 'config.cameras.triggers.reviews.editor_label', + 'mdi:check-circle', + html` + ${this._renderOptionSelector( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_SEVERITIES, + cameraIndex, + ), + this._severities, + { + multiple: true, + label: localize('common.severity'), + }, + )} + ${this._renderSwitch( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION, + cameraIndex, + ), + this._defaults.cameras.triggers.reviews.description, + )} + `, + )} + `, + )} + ${this._putInSubmenu( + MENU_CAMERAS_MEDIA, + cameraIndex, + 'config.cameras.media.editor_label', + 'mdi:play-box-outline', + html` + ${this._renderOptionSelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_MEDIA_TYPE, cameraIndex), + this._cameraMediaTypes, + { + label: localize('config.cameras.media.type'), + }, + )} + ${this._renderOptionSelector( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE, + cameraIndex, + ), + this._thumbnailEventsMediaTypes, + { + label: localize('config.cameras.media.events_type'), + }, + )} + ${this._renderOptionSelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_MEDIA_FOLDERS, cameraIndex), + folderOptions, + { + multiple: true, + label: localize('config.cameras.media.folders'), + }, + )} `, )} ${this._putInSubmenu( @@ -2731,9 +2845,9 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard ? html`
${cameras.map((_, index) => - this._renderCamera(cameras, index, entities), + this._renderCamera(cameras, index, entities, folders), )} - ${this._renderCamera(cameras, cameras.length, entities, true)} + ${this._renderCamera(cameras, cameras.length, entities, folders, true)}
` : ''} @@ -2784,6 +2898,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard ${this._renderMenuButton('cameras') /* */} ${this._renderMenuButton('substreams') /* */} ${this._renderMenuButton('live') /* */} + ${this._renderMenuButton('reviews') /* */} ${this._renderMenuButton('clips') /* */} ${this._renderMenuButton('snapshots')} ${this._renderMenuButton('recordings')} @@ -2935,11 +3050,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, this._defaults.live.controls.thumbnails, { - configPathMediaType: CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE, - configPathEventsMediaType: - CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE, configPathMode: CONF_LIVE_CONTROLS_THUMBNAILS_MODE, }, )} @@ -2949,7 +3063,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard CONF_LIVE_CONTROLS_TIMELINE_STYLE, CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS, CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, - CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE, CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS, CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H, this._defaults.live.controls.timeline.show_recordings, @@ -3035,6 +3148,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, this._defaults.media_gallery.controls.thumbnails, )} ${this._renderFilterControls( @@ -3137,6 +3252,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, this._defaults.media_viewer.controls.thumbnails, { configPathMode: CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE, @@ -3148,7 +3265,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H, this._defaults.media_viewer.controls.timeline.show_recordings, @@ -3179,7 +3295,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard CONF_TIMELINE_STYLE, CONF_TIMELINE_WINDOW_SECONDS, CONF_TIMELINE_CLUSTERING_THRESHOLD, - CONF_TIMELINE_EVENTS_MEDIA_TYPE, CONF_TIMELINE_SHOW_RECORDINGS, CONF_TIMELINE_FORMAT_24H, this._defaults.timeline.show_recordings, @@ -3192,6 +3307,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, + CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, + CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, this._defaults.timeline.controls.thumbnails, { configPathMode: CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE, diff --git a/src/ha/browse-media/item-factory.ts b/src/ha/browse-media/item-factory.ts index 02a0ebbb..a20322ed 100644 --- a/src/ha/browse-media/item-factory.ts +++ b/src/ha/browse-media/item-factory.ts @@ -13,8 +13,8 @@ export class BrowseMediaViewItemFactory { options?: ViewMediaSourceOptions, ): ViewItem | null { if (browseMedia.can_expand) { - return options?.folder - ? new BrowseMediaViewFolder(options.folder, browseMedia) + return options?.folder && options?.path + ? new BrowseMediaViewFolder(options.folder, options.path, browseMedia) : null; } diff --git a/src/ha/browse-media/item.ts b/src/ha/browse-media/item.ts index 224f7585..c003b8d3 100644 --- a/src/ha/browse-media/item.ts +++ b/src/ha/browse-media/item.ts @@ -1,5 +1,6 @@ import { format } from 'date-fns'; import { isEqual } from 'lodash-es'; +import { FolderPathComponent } from '../../card-controller/folders/types'; import { FolderConfig } from '../../config/schema/folders'; import { formatDateAndTime } from '../../utils/basic'; import { @@ -116,8 +117,12 @@ export class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMed export class BrowseMediaViewFolder extends ViewFolder { private _browseMedia: RichBrowseMedia; - constructor(folder: FolderConfig, browseMedia: RichBrowseMedia) { - super(folder, { + constructor( + folder: FolderConfig, + path: readonly FolderPathComponent[], + browseMedia: RichBrowseMedia, + ) { + super(folder, path, { id: browseMedia.media_content_id, icon: getIcon(browseMedia.children_media_class ?? browseMedia.media_class), title: browseMedia.title, diff --git a/src/ha/ws-request.ts b/src/ha/ws-request.ts index 097be5c3..bae10ded 100644 --- a/src/ha/ws-request.ts +++ b/src/ha/ws-request.ts @@ -37,11 +37,11 @@ export async function homeAssistantWSRequest( // Some endpoints in Home Assistant pass JSON directly though, these end up // wrapped in a string and must be unwrapped first. return schema.parse(passthrough ? JSON.parse(response as string) : response); - } catch (e) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_e) { throw new AdvancedCameraCardError(localize('error.invalid_response'), { request: request, response: response, - error: e, }); } } diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index c7b7eda2..614533de 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -109,6 +109,15 @@ } }, "common": { + "media_types": { + "events": "Esdeveniments", + "recordings": "Enregistraments" + }, + "events_media_types": { + "all": "Tots els tipus de mitjans", + "clips": "Clips", + "snapshots": "Imatges instantànies" + }, "controls": { "builtin": "Controls de vídeo integrats", "filter": { @@ -134,15 +143,7 @@ "thumbnails": { "editor_label": "Miniatures", "events_media_type": "Si voleu mostrar miniatures d'esdeveniments de clips o instantànies", - "events_media_types": { - "clips": "Miniatures de clips", - "snapshots": "Miniatures d'instantànies" - }, "media_type": "Si voleu mostrar miniatures d'esdeveniments o enregistraments", - "media_types": { - "events": "Esdeveniments", - "recordings": "Enregistraments" - }, "mode": "Mode de miniatures", "modes": { "above": "Miniatures a dalt", @@ -203,11 +204,6 @@ "timeline": { "clustering_threshold": "El recompte d'esdeveniments en què s'agrupen (0=sense agrupació)", "events_media_type": "Els esdeveniments multimèdia que mostra la línia de temps", - "events_media_types": { - "all": "Tots els tipus de mitjans", - "clips": "Clips", - "snapshots": "Imatges instantànies" - }, "show_recordings": "Mostra les gravacions", "style": "Estil de cronologia", "styles": { diff --git a/src/localize/languages/de.json b/src/localize/languages/de.json index 8208a79b..7cd01ab8 100644 --- a/src/localize/languages/de.json +++ b/src/localize/languages/de.json @@ -116,14 +116,14 @@ } }, "common": { + "media_types": { + "events": "Ereignisse", + "recordings": "Aufzeichnungen" + }, "controls": { "builtin": "Eingebettete Video Bedienelemente", "thumbnails": { - "media_type": "Ob Vorschau von Ereignissen oder Aufzeichnungen angezeigt werden", - "media_types": { - "events": "Ereignisse", - "recordings": "Aufzeichnungen" - } + "media_type": "Ob Vorschau von Ereignissen oder Aufzeichnungen angezeigt werden" }, "timeline": { "format": { diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 3bf302f7..6880f24b 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -6,11 +6,22 @@ "common": { "advanced_camera_card": "Advanced Camera Card", "advanced_camera_card_description": "An Advanced Camera Card", + "up": "Up", "folder": "Folder", "live": "Live", "no_folder": "No folder to display", "no_media": "No media to display", "recordings": "Recordings", + "set_reviews": { + "reviewed": "Mark as reviewed", + "unreviewed": "Mark as unreviewed" + }, + "severities": { + "high": "High", + "low": "Low", + "medium": "Medium" + }, + "severity": "Severity", "version": "Version" }, "config": { @@ -83,9 +94,9 @@ "rotation": "Rotation", "rotations": { "0": "No rotation", - "90": "90 degrees clockwise", "180": "180 degrees clockwise", - "270": "270 degrees clockwise" + "270": "270 degrees clockwise", + "90": "90 degrees clockwise" } }, "engines": { @@ -130,6 +141,12 @@ "jsmpeg": "JSMpeg", "webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)" }, + "media": { + "editor_label": "Default Media", + "events_type": "Events media subtype", + "folders": "Folder IDs", + "type": "Default media type" + }, "motioneye": { "editor_label": "MotionEye options", "images": { @@ -187,7 +204,11 @@ "snapshots": "Events with new snapshots" }, "motion": "Trigger by auto-detecting the motion sensor", - "occupancy": "Trigger by auto-detecting the occupancy sensor" + "occupancy": "Trigger by auto-detecting the occupancy sensor", + "reviews": { + "description": "Trigger on description updates", + "editor_label": "Review Triggers" + } }, "webrtc_card": { "editor_label": "WebRTC Card Options", @@ -221,15 +242,7 @@ "thumbnails": { "editor_label": "Thumbnails", "events_media_type": "Whether to show event thumbnails of clips or snapshots", - "events_media_types": { - "clips": "Clip thumbnails", - "snapshots": "Snapshot thumbnails" - }, - "media_type": "Whether to show thumnails of events or recordings", - "media_types": { - "events": "Events", - "recordings": "Recordings" - }, + "media_type": "What to show thumbnails of", "mode": "Thumbnails mode", "modes": { "above": "Thumbnails above", @@ -241,6 +254,8 @@ "show_details": "Show details with thumbnails", "show_download_control": "Show download control on thumbnails", "show_favorite_control": "Show favorite control on thumbnails", + "show_info_control": "Show info control on thumbnails", + "show_review_control": "Show review control on thumbnails", "show_timeline_control": "Show timeline control on thumbnails", "size": "Thumbnails size in pixels" }, @@ -279,6 +294,11 @@ "grid_selected_width_factor": "Increase selected media width by this factor", "mode": "Mode" }, + "events_media_types": { + "all": "All", + "clips": "Clips", + "snapshots": "Snapshots" + }, "image": { "entity": "Entity for use with entity mode", "entity_parameters": "Query parameters added to the entity-based picture URLs (e.g. width=1920&height=1080)", @@ -302,14 +322,16 @@ "unselected": "On unselection", "visible": "On browser/tab visibility" }, + "media_types": { + "auto": "Automatic", + "events": "Events", + "folder": "Folder", + "recordings": "Recordings", + "reviews": "Reviews" + }, "timeline": { "clustering_threshold": "The count of events at which they are clustered (0=no clustering)", "events_media_type": "The events media the timeline displays", - "events_media_types": { - "all": "All event media types", - "clips": "Clips", - "snapshots": "Snapshots" - }, "show_recordings": "Show recordings", "style": "Timeline style", "styles": { @@ -433,6 +455,7 @@ "fullscreen": "Fullscreen", "icon": "Icon", "image": "Image", + "info": "Info", "iris": "Iris / Default View / Unhide menu", "live": "Live", "media_player": "Send to media player", @@ -444,7 +467,9 @@ "ptz_controls": "Show PTZ controls", "ptz_home": "PTZ Home", "recordings": "Recordings", + "reviews": "Reviews", "screenshot": "Screenshot", + "set_review": "Set review status", "snapshots": "Snapshots", "substreams": "Substream(s)", "timeline": "Timeline", @@ -604,6 +629,8 @@ "live": "Live view", "recording": "Most recent recording", "recordings": "Recordings gallery", + "review": "Most recent media to review", + "reviews": "Reviews gallery", "snapshot": "Most recent snapshot", "snapshots": "Snapshots gallery", "timeline": "Timeline view" @@ -734,12 +761,16 @@ "media_types": { "clips": "Clips", "recordings": "Recordings", + "reviews": "Reviews", "snapshots": "Snapshots" }, "not_favorite": "Not Favorite", + "not_reviewed": "Not Reviewed", + "reviewed": "Reviewed", "select_camera": "Select camera...", "select_favorite": "Select favorite...", "select_media_type": "Select media type...", + "select_reviewed": "Select reviewed...", "select_tag": "Select tag...", "select_what": "Select what...", "select_when": "Select when...", @@ -765,6 +796,7 @@ "duration": "Duration", "events": "Events", "in_progress": "In Progress", + "info": "Info", "no_thumbnail": "No thumbnail available", "retain_indefinitely": "Media will be indefinitely retained", "score": "Score", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index dc13066a..7b73f21b 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -178,6 +178,15 @@ } }, "common": { + "media_types": { + "events": "Événements", + "recordings": "Enregistrements" + }, + "events_media_types": { + "all": "Tous types de médias", + "clips": "Extraits", + "snapshots": "Instantanés" + }, "controls": { "builtin": "Commandes vidéo intégrées", "filter": { @@ -203,15 +212,7 @@ "thumbnails": { "editor_label": "Vignettes", "events_media_type": "S'il faut afficher des vignettes de clips ou des instantanés", - "events_media_types": { - "clips": "Vignettes de clips", - "snapshots": "Vignettes d'instantanés" - }, "media_type": "Afficher ou non les vignettes des événements ou des enregistrements", - "media_types": { - "events": "Événements", - "recordings": "Enregistrements" - }, "mode": "Mode vignettes", "modes": { "above": "Vignettes en haut", @@ -281,11 +282,6 @@ "timeline": { "clustering_threshold": "Nombre d'événements pour lesquels ils sont regroupés (0 = pas de clustering)", "events_media_type": "Médias affichés par la chronologie", - "events_media_types": { - "all": "Tous types de médias", - "clips": "Extraits", - "snapshots": "Instantanés" - }, "show_recordings": "Afficher les enregistrements", "style": "Style de chronologie", "styles": { diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 3f968034..23a8bdea 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -94,6 +94,11 @@ } }, "common": { + "events_media_types": { + "all": "Tutti i tipi di media", + "clips": "Clip", + "snapshots": "Istantanee" + }, "controls": { "filter": { "editor_label": "Filtro multimediale", @@ -118,10 +123,6 @@ "thumbnails": { "editor_label": "Miniature", "events_media_type": "Se mostrare miniature di clip o istantanee", - "events_media_types": { - "clips": "Miniature di clip", - "snapshots": "Miniature istantanee" - }, "mode": "Modalità miniatura", "modes": { "above": "Miniature sopra", @@ -166,11 +167,6 @@ "timeline": { "clustering_threshold": "Il conteggio degli eventi in cui sono raggruppati (0 = nessun clustering)", "events_media_type": "I media vengono visualizzati la sequenza temporale", - "events_media_types": { - "all": "Tutti i tipi di media", - "clips": "Clip", - "snapshots": "Istantanee" - }, "show_recordings": "Mostra registrazioni", "window_seconds": "La lunghezza predefinita della vista della sequenza temporale in secondi" } diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 6066a698..b0bb4c48 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -94,6 +94,11 @@ } }, "common": { + "events_media_types": { + "all": "Todos os tipos de mídia", + "clips": "Clipes", + "snapshots": "Instantâneos" + }, "controls": { "filter": { "editor_label": "Filtro de Mídia", @@ -118,10 +123,6 @@ "thumbnails": { "editor_label": "Miniaturas", "events_media_type": "Se deve mostrar miniaturas de clipes ou snapshots", - "events_media_types": { - "clips": "Miniaturas de clipes", - "snapshots": "Miniaturas de Snapshots" - }, "mode": "Modo de miniaturas", "modes": { "above": "Miniaturas acima da mídia", @@ -166,11 +167,6 @@ "timeline": { "clustering_threshold": "A contagem de eventos nos quais eles são agrupados (0 = sem agrupamento)", "events_media_type": "A mídia que a linha do tempo exibe", - "events_media_types": { - "all": "Todos os tipos de mídia", - "clips": "Clipes", - "snapshots": "Instantâneos" - }, "show_recordings": "Mostrar gravações", "window_seconds": "A duração padrão da visualização da linha do tempo em segundos" } diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index f5740bca..4a0882e9 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -94,6 +94,11 @@ } }, "common": { + "events_media_types": { + "all": "Todos os tipos de mídia", + "clips": "Clipes", + "snapshots": "Instantâneos" + }, "controls": { "filter": { "editor_label": "Editor de titulos", @@ -118,10 +123,6 @@ "thumbnails": { "editor_label": "Editor de titulos", "events_media_type": "Mostrar miniaturas de clipes ou snapshots", - "events_media_types": { - "clips": "Miniaturas de clipes", - "snapshots": "Miniaturas de Snapshots" - }, "mode": "Modos", "modes": { "above": "Miniaturas acima da mídia", @@ -166,11 +167,6 @@ "timeline": { "clustering_threshold": "A contagem de eventos nos quais eles são agrupados (0 = sem agrupamento)", "events_media_type": "A mídia que a linha do tempo exibe", - "events_media_types": { - "all": "Todos os tipos de mídia", - "clips": "Clipes", - "snapshots": "Instantâneos" - }, "show_recordings": "Mostrar gravações", "window_seconds": "A duração padrão da visualização da linha do tempo em segundos" } diff --git a/src/query-source.ts b/src/query-source.ts new file mode 100644 index 00000000..869129b7 --- /dev/null +++ b/src/query-source.ts @@ -0,0 +1,52 @@ +export enum QuerySource { + // Camera queries are handled by CameraManager. + Camera = 'camera', + + // Folder queries are handled by FolderManager. + Folder = 'folder', +} + +export interface BaseQuery { + source: QuerySource; +} + +/** + * Filter properties that can be applied to queries. + * Not all query types support all filters -- engines reject unsupported ones. + */ +export interface QueryFilters { + favorite?: boolean; + tags?: Set; + what?: Set; + where?: Set; + reviewed?: boolean; +} + +/** + * Declares which filters a query handler supports. + * Used by hasUnsupportedFilters() to check compatibility. + */ +interface SupportedFilters { + favorite?: boolean; + tags?: boolean; + what?: boolean; + where?: boolean; + reviewed?: boolean; +} + +/** + * Check if a query has filters that aren't supported. + * Returns true if the query has any filter that isn't in the supported set. + */ +export const hasUnsupportedFilters = ( + query: QueryFilters, + supported: SupportedFilters = {}, +): boolean => { + return ( + (!supported.favorite && query.favorite !== undefined) || + (!supported.tags && !!query.tags?.size) || + (!supported.what && !!query.what?.size) || + (!supported.where && !!query.where?.size) || + (!supported.reviewed && query.reviewed !== undefined) + ); +}; diff --git a/src/scss/overlay-message.scss b/src/scss/overlay-message.scss new file mode 100644 index 00000000..60a16a1b --- /dev/null +++ b/src/scss/overlay-message.scss @@ -0,0 +1,280 @@ +@use './z-index.scss' as *; + +:host { + position: absolute; + inset: 0; + display: flex; + justify-content: center; + align-items: flex-end; + padding: 24px; + pointer-events: none; + z-index: $z-index-overlay-message; +} + +.backdrop { + position: absolute; + inset: 0; + pointer-events: auto; +} + +.message { + position: relative; + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 12px; + + width: min(92%, 400px); + max-height: calc(100% - 48px); + padding: 20px 24px; + padding-right: 48px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--advanced-camera-card-overlay-message-scrollbar-color) + transparent; + + // Glassmorphism background + background: var(--advanced-camera-card-overlay-message-background); + backdrop-filter: var(--advanced-camera-card-overlay-message-backdrop-filter); + -webkit-backdrop-filter: var(--advanced-camera-card-overlay-message-backdrop-filter); + + // Borders & Edge highlights + border: var(--advanced-camera-card-overlay-message-border); + border-top: var(--advanced-camera-card-overlay-message-border-top); + border-radius: var(--advanced-camera-card-border-radius-final); + + // Shadows + box-shadow: var( + --advanced-camera-card-box-shadow-override, + var(--advanced-camera-card-overlay-message-box-shadow) + ); + + pointer-events: auto; + + // Entry animation (auto-plays on render) + animation: slideUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; +} + +// Exit animation +.message.exiting { + animation: slideDown 0.25s ease-out forwards; +} + +@keyframes slideUp { + 0% { + opacity: 0; + transform: translateY(20px) scale(0.95); + } + 60% { + opacity: 1; + transform: translateY(-6px) scale(1.02); + } + 80% { + transform: translateY(3px) scale(0.98); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes slideDown { + 0% { + opacity: 1; + transform: translateY(0) scale(1); + } + 20% { + transform: translateY(-4px) scale(1.02); + } + 100% { + opacity: 0; + transform: translateY(20px) scale(0.95); + } +} + +.icon { + flex-shrink: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + color: var(--advanced-camera-card-overlay-message-icon-color); + filter: drop-shadow( + 0 0 8px + color-mix( + in oklab, + var(--advanced-camera-card-overlay-message-icon-color), + transparent 60% + ) + ); +} + +.icon advanced-camera-card-icon { + --mdc-icon-size: 20px; +} + +.text { + flex: 1; + font-size: 15px; + font-weight: 400; + line-height: 1.5; + color: var(--advanced-camera-card-overlay-message-text-color); + word-break: break-word; +} + +.controls { + position: absolute; + top: 8px; + right: 48px; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: var(--advanced-camera-card-button-border-radius); + + background: color-mix( + in srgb, + var(--advanced-camera-card-overlay-message-close-color), + transparent 92% + ); +} + +.control { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--advanced-camera-card-button-border-radius); + + color: color-mix( + in srgb, + var(--advanced-camera-card-overlay-message-close-color), + transparent 40% + ); + + cursor: pointer; + transition: all 0.15s ease; + + advanced-camera-card-icon { + --mdc-icon-size: 16px; + } + + &:hover { + filter: brightness(1.2); + } + + // Base control hover color transition (from dimmed to full). + &:not([class^='emphasis-']):hover { + color: var(--advanced-camera-card-overlay-message-close-color); + } + + &.emphasis-low { + color: var(--advanced-camera-card-foreground-severity-low-color); + } + &.emphasis-medium { + color: var(--advanced-camera-card-foreground-severity-medium-color); + } + &.emphasis-high { + color: var(--advanced-camera-card-foreground-severity-high-color); + } +} + +.close { + position: absolute; + top: 8px; + right: 8px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--advanced-camera-card-button-border-radius); + + background: color-mix( + in srgb, + var(--advanced-camera-card-overlay-message-close-color), + transparent 95% + ); + color: color-mix( + in srgb, + var(--advanced-camera-card-overlay-message-close-color), + transparent 40% + ); + + cursor: pointer; + transition: all 0.15s ease; +} + +.close:hover { + background: color-mix( + in srgb, + var(--advanced-camera-card-overlay-message-close-color), + transparent 90% + ); + color: var(--advanced-camera-card-overlay-message-close-color); +} + +.close advanced-camera-card-icon { + --mdc-icon-size: 18px; +} + +.details { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 8px 16px; + flex: 1; + min-width: 0; +} + +.detail { + display: flex; + align-items: center; + gap: 8px; + color: var(--advanced-camera-card-overlay-message-text-color); + font-size: 14px; + line-height: 1.4; + + advanced-camera-card-icon { + flex-shrink: 0; + --mdc-icon-size: 18px; + opacity: 0.7; + } + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + + &.heading { + width: 100%; + font-weight: 600; + font-size: 16px; + margin-bottom: 4px; + + advanced-camera-card-icon { + opacity: 1; + } + } +} + +.description { + width: 100%; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid + color-mix( + in srgb, + var(--advanced-camera-card-overlay-message-text-color), + transparent 80% + ); + color: var(--advanced-camera-card-overlay-message-text-color); + font-size: 14px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/src/scss/status.scss b/src/scss/status.scss index 962cc6e5..2570e839 100644 --- a/src/scss/status.scss +++ b/src/scss/status.scss @@ -82,3 +82,17 @@ advanced-camera-card-icon.item { // Further theme related styling is dynamically applied by `status-bar.ts`, see // `_renderPerInstanceStyle`. + +/***************** + * Severity colors + *****************/ + +.item[data-severity='high'] { + color: var(--advanced-camera-card-foreground-severity-high-color); +} +.item[data-severity='medium'] { + color: var(--advanced-camera-card-foreground-severity-medium-color); +} +.item[data-severity='low'] { + color: var(--advanced-camera-card-foreground-severity-low-color); +} diff --git a/src/scss/themes/base.scss b/src/scss/themes/base.scss index 3da18fea..f1034e51 100644 --- a/src/scss/themes/base.scss +++ b/src/scss/themes/base.scss @@ -38,6 +38,16 @@ ); --advanced-camera-card-control-foreground: var(--app-header-text-color); + --advanced-camera-card-foreground-severity-high-color: var( + --ha-color-fill-danger-loud-resting + ); + --advanced-camera-card-foreground-severity-medium-color: var( + --ha-color-fill-warning-loud-resting + ); + --advanced-camera-card-foreground-severity-low-color: var( + --ha-color-fill-neutral-loud-resting + ); + /********* * Buttons *********/ @@ -51,7 +61,9 @@ ); // The border radius of buttons. - --advanced-camera-card-button-border-radius: 50%; + // We use a large viewport-relative value to ensure that rectangular + // elements render as pills/capsules rather than ovals. + --advanced-camera-card-button-border-radius: 100vmax; /****** * Menu @@ -126,6 +138,16 @@ --advanced-camera-card-thumbnail-icon-color: var( --advanced-camera-card-foreground-primary ); + --advanced-camera-card-thumbnail-icon-severity-high-color: var( + --advanced-camera-card-foreground-severity-high-color + ); + --advanced-camera-card-thumbnail-icon-severity-medium-color: var( + --advanced-camera-card-foreground-severity-medium-color + ); + --advanced-camera-card-thumbnail-icon-severity-low-color: var( + --advanced-camera-card-foreground-severity-low-color + ); + --advanced-camera-card-thumbnail-border-color: var( --advanced-camera-card-foreground-primary ); @@ -135,6 +157,9 @@ gold, transparent 25% ); + --advanced-camera-card-thumbnail-icon-review-color: var( + --advanced-camera-card-thumbnail-icon-color + ); --advanced-camera-card-thumbnail-text-color: var(--advanced-camera-card-text-color); /********** @@ -164,6 +189,16 @@ --advanced-camera-card-active-color ); + --advanced-camera-card-timeline-item-severity-high-color: var( + --advanced-camera-card-foreground-severity-high-color + ); + --advanced-camera-card-timeline-item-severity-medium-color: var( + --advanced-camera-card-foreground-severity-medium-color + ); + --advanced-camera-card-timeline-item-severity-low-color: var( + --advanced-camera-card-foreground-severity-low-color + ); + /******************* * Loading Indicator *******************/ @@ -227,4 +262,25 @@ var(--advanced-camera-card-ptz-background), transparent var(--advanced-camera-card-control-background-opacity) ); + + /****************** + * Overlay Message + ******************/ + --advanced-camera-card-overlay-message-background: linear-gradient( + 135deg, + rgba(35, 35, 35, 0.75) 0%, + rgba(15, 15, 15, 0.65) 100% + ); + --advanced-camera-card-overlay-message-backdrop-filter: blur(20px) saturate(180%); + --advanced-camera-card-overlay-message-border: 1px solid rgba(255, 255, 255, 0.1); + --advanced-camera-card-overlay-message-border-top: 1px solid rgba(255, 255, 255, 0.15); + --advanced-camera-card-overlay-message-box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5), + 0 2px 8px 0 rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(255, 255, 255, 0.05); + + --advanced-camera-card-overlay-message-text-color: #ffffff; + --advanced-camera-card-overlay-message-icon-color: var( + --advanced-camera-card-foreground-primary + ); + --advanced-camera-card-overlay-message-scrollbar-color: rgba(255, 255, 255, 0.2); + --advanced-camera-card-overlay-message-close-color: #ffffff; } diff --git a/src/scss/themes/light.scss b/src/scss/themes/light.scss index 3f7043c6..9f47986c 100644 --- a/src/scss/themes/light.scss +++ b/src/scss/themes/light.scss @@ -15,4 +15,20 @@ // See: https://github.com/home-assistant/frontend/blob/dev/src/common/dom/apply_themes_on_element.ts --app-header-background-color: #03a9f4; + + /****************** + * Overlay Message + ******************/ + --advanced-camera-card-overlay-message-background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.85) 0%, + rgba(235, 235, 235, 0.75) 100% + ); + --advanced-camera-card-overlay-message-text-color: var(--primary-text-color); + --advanced-camera-card-overlay-message-border: 1px solid rgba(0, 0, 0, 0.3); + --advanced-camera-card-overlay-message-border-top: 1px solid rgba(0, 0, 0, 0.2); + --advanced-camera-card-overlay-message-box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3), + 0 2px 8px 0 rgba(0, 0, 0, 0.2), inset 0 0 0 1px rgba(255, 255, 255, 0.8); + --advanced-camera-card-overlay-message-scrollbar-color: rgba(0, 0, 0, 0.5); + --advanced-camera-card-overlay-message-close-color: #000000; } diff --git a/src/scss/thumbnail-details.scss b/src/scss/thumbnail-details.scss index 947ff5e0..6712acb3 100644 --- a/src/scss/thumbnail-details.scss +++ b/src/scss/thumbnail-details.scss @@ -2,37 +2,57 @@ display: flex; flex-direction: column; - width: 100%; - margin-left: 5px; - padding: 5px; - color: var(--advanced-camera-card-thumbnail-text-color); - overflow: hidden; - column-gap: 5%; -} + // Wrap "details that don't fit" off the side instead of showing part of a + // detail. + align-content: flex-start; + flex-wrap: wrap; -div.heading { - font-size: 1.2rem; - font-weight: bold; -} - -div.details { - flex: 1; justify-content: center; - display: flex; - flex-direction: column; - flex-wrap: wrap; - --mdc-icon-size: 16px; + width: 100%; // Details panel can shrink as well as grow. min-height: 0px; + max-height: var(--advanced-camera-card-thumbnail-size); + + margin-left: 5px; + + color: var(--advanced-camera-card-thumbnail-text-color); + + overflow: hidden; + + --mdc-icon-size: 16px; } -div.details div { +div { + width: 100%; + display: flex; align-items: center; + gap: 5px; + + container-type: normal; + font-size: 0.95cqh; + + &.heading { + font-weight: bold; + font-size: 1cqh; + } + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } } -div.details div * { - margin: 0px 3px; +[severity='high'] { + color: var(--advanced-camera-card-thumbnail-icon-severity-high-color); +} +[severity='medium'] { + color: var(--advanced-camera-card-thumbnail-icon-severity-medium-color); +} +[severity='low'] { + color: var(--advanced-camera-card-thumbnail-icon-severity-low-color); } diff --git a/src/scss/thumbnail-feature-thumbnail.scss b/src/scss/thumbnail-feature-thumbnail.scss index 3e937670..f2c5e125 100644 --- a/src/scss/thumbnail-feature-thumbnail.scss +++ b/src/scss/thumbnail-feature-thumbnail.scss @@ -1,10 +1,9 @@ :host { - display: block; + display: flex; overflow: hidden; aspect-ratio: 1 / 1; - display: flex; justify-content: center; align-items: center; } diff --git a/src/scss/thumbnail-feature.scss b/src/scss/thumbnail-feature.scss index 81d40e04..b1bdbea8 100644 --- a/src/scss/thumbnail-feature.scss +++ b/src/scss/thumbnail-feature.scss @@ -15,24 +15,41 @@ box-sizing: border-box; - // Background icon relative to the thumbnail. + overflow: hidden; + + // Relative for control icons. position: relative; } -advanced-camera-card-thumbnail-feature-thumbnail { - height: 50%; +.media { + // Relative for thumbnail. + position: relative; + width: 100%; + flex: 1; + display: flex; + justify-content: center; + align-items: center; + min-height: 0; + overflow: hidden; } -advanced-camera-card-thumbnail-feature-thumbnail.brand:only-child { - height: 60%; +.media.has-text { + flex: 0 0 50%; } -advanced-camera-card-thumbnail-feature-thumbnail:not(.brand):only-child { + +// Regular camera thumbnails: fill the media container +advanced-camera-card-thumbnail-feature-thumbnail:not(.placeholder) { + width: 100%; height: 100%; + position: absolute; + top: 0; + left: 0; } -advanced-camera-card-icon:only-child { - width: 60%; - height: 60%; - +// Placeholder icons/thumbnails: centered, 90% of container height +advanced-camera-card-thumbnail-feature-thumbnail.placeholder, +advanced-camera-card-icon.placeholder { + height: 90%; + aspect-ratio: 1 / 1; --mdc-icon-size: 100%; } @@ -57,10 +74,57 @@ advanced-camera-card-icon.background { div { text-align: center; color: var(--primary-text-color); + z-index: 1; } div.title { - font-size: 1.5rem; + font-size: 1rem; + line-height: 1.2; } div.subtitle { - font-size: 0.8em; + font-size: 0.75rem; + line-height: 1.2; +} + +advanced-camera-card-icon:not(.placeholder):not(.background) { + position: absolute; + border-radius: 50%; + color: var(--advanced-camera-card-thumbnail-icon-color); + z-index: 2; + + cursor: pointer; + + transition: + opacity 0.2s ease-in-out, + color 0.4s ease-in-out; +} +advanced-camera-card-icon:not(.placeholder):not(.background):hover { + opacity: 1; + color: var(--advanced-camera-card-active-color); +} + +advanced-camera-card-icon.star, +advanced-camera-card-icon.review { + top: 2px; + left: 2px; +} + +advanced-camera-card-icon.star.starred { + color: var(--advanced-camera-card-thumbnail-icon-favorite-color); +} + +advanced-camera-card-icon.timeline { + left: 2px; + bottom: 2px; +} + +advanced-camera-card-icon.info { + right: 2px; + top: 2px; + + cursor: default; +} + +advanced-camera-card-icon.download { + right: 2px; + bottom: 2px; } diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss index a513bea1..6b085dcc 100644 --- a/src/scss/thumbnail.scss +++ b/src/scss/thumbnail.scss @@ -2,6 +2,7 @@ display: flex; flex-direction: row; box-sizing: border-box; + gap: 2px; // Ensure control icons are relative to the thumbnail. position: relative; @@ -19,6 +20,9 @@ border-radius: var(--advanced-camera-card-border-radius-final); padding: 2px; + // Additional padding on the right to match the gap specified above. + padding-right: 4px; + // When details are enabled, use a background color so that the details have // contrast with the background. background-color: var(--advanced-camera-card-thumbnail-background); @@ -28,39 +32,6 @@ transform: scale(1.04); } -advanced-camera-card-icon { - position: absolute; - border-radius: 50%; - color: var(--advanced-camera-card-thumbnail-icon-color); - - cursor: pointer; - - transition: - opacity 0.2s ease-in-out, - color 0.2s ease-in-out; -} -advanced-camera-card-icon:hover { - opacity: 1; -} - -advanced-camera-card-icon.star { - top: 3px; - left: 3px; -} -advanced-camera-card-icon.star.starred { - color: var(--advanced-camera-card-thumbnail-icon-favorite-color); -} - -advanced-camera-card-icon.timeline { - top: 3px; - right: 3px; -} - -advanced-camera-card-icon.download { - right: 3px; - bottom: 3px; -} - advanced-camera-card-thumbnail-details { flex: 1; } diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss index 5911af93..e2d2fffd 100644 --- a/src/scss/timeline-core.scss +++ b/src/scss/timeline-core.scss @@ -65,6 +65,18 @@ div.timeline { border-color ease-in-out 1s, box-shadow ease-in-out 1s; } +.vis-item[data-severity='high'] { + border-color: var(--advanced-camera-card-timeline-item-severity-high-color); + background-color: var(--advanced-camera-card-timeline-item-severity-high-color); +} +.vis-item[data-severity='medium'] { + border-color: var(--advanced-camera-card-timeline-item-severity-medium-color); + background-color: var(--advanced-camera-card-timeline-item-severity-medium-color); +} +.vis-item[data-severity='low'] { + border-color: var(--advanced-camera-card-timeline-item-severity-low-color); + background-color: var(--advanced-camera-card-timeline-item-severity-low-color); +} .vis-item.vis-selected { border-color: var(--advanced-camera-card-active-color); background-color: var(--advanced-camera-card-active-color); diff --git a/src/scss/z-index.scss b/src/scss/z-index.scss index 2d5139b4..91b77880 100644 --- a/src/scss/z-index.scss +++ b/src/scss/z-index.scss @@ -16,5 +16,6 @@ $z-index-submenu: 5; // Need menu to render above drawer (so the drawer button is below menu-submenus) $z-index-menu: 4; +$z-index-overlay-message: 4; $z-index-drawer: 3; $z-index-status-bar: 2; diff --git a/src/types.ts b/src/types.ts index bd060151..ba1fbfd9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,8 +2,9 @@ import { z } from 'zod'; import type { EffectOptions } from './components-lib/effects/types'; import type { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types'; -export type ClipsOrSnapshots = 'clips' | 'snapshots'; -export type ClipsOrSnapshotsOrAll = 'clips' | 'snapshots' | 'all'; +// UI-facing media types for galleries and views. +export const VIEW_MEDIA_TYPES = ['clips', 'snapshots', 'recordings', 'reviews'] as const; +export type ViewMediaType = (typeof VIEW_MEDIA_TYPES)[number]; export class AdvancedCameraCardError extends Error { context?: unknown; @@ -62,6 +63,24 @@ export interface Message { url?: MessageURL; } +export interface MetadataField { + title: string; + icon?: Icon; + hint?: string; + emphasis?: Severity; +} + +export interface OverlayMessageControl extends MetadataField { + callback: () => OverlayMessage | null | Promise; +} + +export interface OverlayMessage { + heading?: MetadataField; + controls?: OverlayMessageControl[]; + details?: MetadataField[]; + text?: string; +} + export type WebkitHTMLVideoElement = HTMLVideoElement & { webkitDisplayingFullscreen: boolean; webkitSupportsFullscreen: boolean; @@ -124,6 +143,7 @@ export interface CapabilitiesRaw { clips?: boolean; recordings?: boolean; snapshots?: boolean; + reviews?: boolean; 'favorite-events'?: boolean; 'favorite-recordings'?: boolean; @@ -151,6 +171,7 @@ export const capabilityKeys: readonly [CapabilityKey, ...CapabilityKey[]] = [ 'menu', 'ptz', 'recordings', + 'reviews', 'seek', 'snapshots', 'substream', @@ -194,3 +215,6 @@ export interface EffectsControllerAPI { stopEffect(effect: EffectName): void; toggleEffect(effect: EffectName, options?: EffectOptions): Promise; } + +export const SEVERITIES = ['high', 'medium', 'low'] as const; +export type Severity = (typeof SEVERITIES)[number]; diff --git a/src/utils/action.ts b/src/utils/action.ts index 1d2aee2b..e68809c9 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -24,6 +24,7 @@ import { PTZActionConfig, PTZActionPhase, } from '../config/schema/actions/custom/ptz.js'; +import { SetReviewActionConfig } from '../config/schema/actions/custom/set-review.js'; import { SubstreamSelectActionConfig } from '../config/schema/actions/custom/substream-select.js'; import { ViewActionConfig } from '../config/schema/actions/custom/view.js'; import { PerformActionActionConfig } from '../config/schema/actions/stock/perform-action.js'; @@ -34,7 +35,7 @@ import { } from '../config/schema/actions/types.js'; import { AdvancedCameraCardUserSpecifiedView } from '../config/schema/common/const.js'; import { ServiceCallRequest } from '../ha/types.js'; -import { EffectName } from '../types.js'; +import type { EffectName } from '../types.js'; import { arrayify } from './basic.js'; export function createGeneralAction( @@ -260,6 +261,14 @@ export function createEffectAction( }; } +export function createSetReviewAction(reviewed?: boolean): SetReviewActionConfig { + return { + action: 'fire-dom-event', + advanced_camera_card_action: 'set_review', + reviewed, + }; +} + /** * Get an action configuration given a config and an interaction (e.g. 'tap'). * @param interaction The interaction: `tap`, `hold` or `double_tap` diff --git a/src/utils/basic.ts b/src/utils/basic.ts index dd63b8f1..1c40f559 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -8,8 +8,6 @@ import { StyleInfo } from 'lit/directives/style-map'; import { isEqualWith, mergeWith, round, uniq } from 'lodash-es'; import { AdvancedCameraCardError } from '../types'; -export type ModifyInterface = Omit & R; - /** * Prettify a title by converting '_' to spaces and capitalizing words. * @param input The input string. diff --git a/src/utils/embla/carousel-controller.ts b/src/utils/embla/carousel-controller.ts index 54d1598e..3a2de016 100644 --- a/src/utils/embla/carousel-controller.ts +++ b/src/utils/embla/carousel-controller.ts @@ -95,6 +95,9 @@ export class CarouselController { } public selectSlide(index: number): void { + if (index < 0 || index >= this._carousel.slideNodes().length) { + return; + } this._carousel.scrollTo(index, this._transitionEffect === 'none'); // This event exists to allow the caller to know the difference between @@ -102,6 +105,10 @@ export class CarouselController { // (e.g. carousel drags). See the note in auto-media-loaded-info.ts on how // this is used. const newSlide = this.getSlide(index); + + /* istanbul ignore if: defensive guard for getSlide returning null which can + only happen with an index out of bounds, which is guarded against above -- + @preserve */ if (newSlide) { fireAdvancedCameraCardEvent( this._parent, @@ -115,11 +122,10 @@ export class CarouselController { } protected _refreshCarouselContents = (): void => { - const newSlides = getChildrenFromElement(this._parent); - const slidesChanged = !isEqual(this._carousel.slideNodes(), newSlides); + const slides = getChildrenFromElement(this._parent); + const slidesChanged = !isEqual(this._carousel.slideNodes(), slides); if (slidesChanged) { - this._carousel.destroy(); - this._carousel = this._createCarousel(newSlides); + this._carousel.reInit({ slides }); } }; diff --git a/src/utils/folder.ts b/src/utils/folder.ts new file mode 100644 index 00000000..46adff7f --- /dev/null +++ b/src/utils/folder.ts @@ -0,0 +1,17 @@ +import { FolderConfig } from '../config/schema/folders'; +import { RawAdvancedCameraCardConfig } from '../config/types'; + +/** + * Get a folder id. + * @param config The folder config (either parsed or raw). + * @param index The index of the folder in the config array (used for default ID). + * @returns A folder id. + */ +export function getFolderID( + config?: FolderConfig | RawAdvancedCameraCardConfig | null, + index?: number, +): string { + return ( + (typeof config?.id === 'string' && config.id) || `folder/${(index ?? 0).toString()}` + ); +} diff --git a/src/utils/media-actions.ts b/src/utils/media-actions.ts new file mode 100644 index 00000000..03eb11bc --- /dev/null +++ b/src/utils/media-actions.ts @@ -0,0 +1,92 @@ +import { ViewItemManager } from '../card-controller/view/item-manager'; +import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context'; +import { ViewManagerEpoch } from '../card-controller/view/types'; +import { ViewItem } from '../view/item'; +import { ViewItemClassifier } from '../view/item-classifier'; +import { errorToConsole } from './basic'; + +interface MediaActionOptions { + viewItemManager?: ViewItemManager; + viewManagerEpoch?: ViewManagerEpoch; +} + +export async function toggleReviewed( + item: ViewItem, + options: MediaActionOptions, +): Promise { + if (!ViewItemClassifier.isReview(item) || !options.viewItemManager) { + return false; + } + + const newState = !item.isReviewed(); + try { + await options.viewItemManager.reviewMedia(item, newState); + } catch (e) { + errorToConsole(e as Error); + return false; + } + item.setReviewed(newState); + + // Remove from view results + const view = options.viewManagerEpoch?.manager.getView(); + if (view?.queryResults) { + options.viewManagerEpoch?.manager.setViewByParameters({ + params: { + queryResults: view.queryResults.clone().removeItem(item), + }, + }); + } + return true; +} + +export async function toggleFavorite( + item: ViewItem, + options: MediaActionOptions, +): Promise { + if (!ViewItemClassifier.isMedia(item) || !options.viewItemManager) { + return false; + } + + const newState = !item.isFavorite(); + try { + await options.viewItemManager.favorite(item, newState); + } catch (e) { + errorToConsole(e as Error); + return false; + } + return true; +} + +export async function downloadMedia( + item: ViewItem, + options: MediaActionOptions, +): Promise { + if (!options.viewItemManager) { + return false; + } + + try { + await options.viewItemManager.download(item); + } catch (e) { + errorToConsole(e as Error); + return false; + } + return true; +} + +export function navigateToTimeline(item: ViewItem, options: MediaActionOptions): void { + if (!options.viewManagerEpoch) { + return; + } + + options.viewManagerEpoch.manager.setViewByParameters({ + params: { + view: 'timeline', + queryResults: options.viewManagerEpoch.manager + .getView() + ?.queryResults?.clone() + .selectResultIfFound((media) => media === item), + }, + modifiers: [new RemoveContextViewModifier(['timeline'])], + }); +} diff --git a/src/utils/overlay-message.ts b/src/utils/overlay-message.ts new file mode 100644 index 00000000..e91c81a9 --- /dev/null +++ b/src/utils/overlay-message.ts @@ -0,0 +1,13 @@ +import { OverlayMessage } from '../types.js'; +import { fireAdvancedCameraCardEvent } from './fire-advanced-camera-card-event.js'; + +export function dispatchShowOverlayMessageEvent( + element: HTMLElement, + message: OverlayMessage, +): void { + fireAdvancedCameraCardEvent(element, 'overlay-message:show', message); +} + +export function dispatchDismissOverlayMessageEvent(element: HTMLElement): void { + fireAdvancedCameraCardEvent(element, 'overlay-message:dismiss'); +} diff --git a/src/utils/review.ts b/src/utils/review.ts new file mode 100644 index 00000000..344396ce --- /dev/null +++ b/src/utils/review.ts @@ -0,0 +1,24 @@ +import { ViewItem } from '../view/item'; + +export interface CardMediaReviewEventTarget extends EventTarget { + addEventListener( + event: 'advanced-camera-card:media:reviewed', + listener: (this: CardMediaReviewEventTarget, ev: CustomEvent) => void, + options?: AddEventListenerOptions | boolean, + ): void; + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions | boolean, + ): void; + removeEventListener( + event: 'advanced-camera-card:media:reviewed', + listener: (this: CardMediaReviewEventTarget, ev: CustomEvent) => void, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; +} diff --git a/src/view/item-classifier.ts b/src/view/item-classifier.ts index 3ae92ce5..558edc73 100644 --- a/src/view/item-classifier.ts +++ b/src/view/item-classifier.ts @@ -1,6 +1,7 @@ import { EventViewMedia, RecordingViewMedia, + ReviewViewMedia, ViewFolder, ViewItem, ViewMedia, @@ -19,6 +20,9 @@ export class ViewItemClassifier { public static isRecording(item?: ViewItem | null): item is RecordingViewMedia { return this.isMedia(item) && item.getMediaType() === 'recording'; } + public static isReview(item?: ViewItem | null): item is ReviewViewMedia { + return this.isMedia(item) && item.getMediaType() === 'review'; + } public static isClip(item?: ViewItem | null): boolean { return this.isMedia(item) && item.getMediaType() === 'clip'; } @@ -26,6 +30,15 @@ export class ViewItemClassifier { return this.isMedia(item) && item.getMediaType() === 'snapshot'; } public static isVideo(item?: ViewItem | null): boolean { - return this.isMedia(item) && (this.isClip(item) || this.isRecording(item)); + return ( + this.isMedia(item) && + (this.isClip(item) || + this.isRecording(item) || + // Reviews always have a video. + this.isReview(item)) + ); + } + public static supportsTimeline(item?: ViewItem | null): item is ViewMedia { + return this.isMedia(item) && !!item.getStartTime(); } } diff --git a/src/view/item.ts b/src/view/item.ts index 8cfcff95..a3f1521e 100644 --- a/src/view/item.ts +++ b/src/view/item.ts @@ -1,9 +1,13 @@ +import { clone } from 'lodash-es'; +import { FolderPathComponent } from '../card-controller/folders/types'; import { FolderConfig } from '../config/schema/folders'; +import { Severity } from '../types'; export enum ViewMediaType { Clip = 'clip', Snapshot = 'snapshot', Recording = 'recording', + Review = 'review', } export enum VideoContentType { @@ -14,6 +18,7 @@ export enum VideoContentType { export interface ViewMediaSourceOptions { cameraID?: string; folder?: FolderConfig; + path?: readonly FolderPathComponent[]; } export class ViewMedia { @@ -59,6 +64,9 @@ export class ViewMedia { public getTitle(): string | null { return null; } + public getDescription(): string | null { + return null; + } public getThumbnail(): string | null { return null; } @@ -68,6 +76,9 @@ export class ViewMedia { public isFavorite(): boolean | null { return null; } + public isReviewed(): boolean | null { + return null; + } public includesTime(seek: Date): boolean { const startTime = this.getStartTime(); const endTime = this.getUsableEndTime(); @@ -80,9 +91,25 @@ export class ViewMedia { public setFavorite(_favorite: boolean): void { return; } + public getSeverity(): Severity | null { + return null; + } public getWhere(): string[] | null { return null; } + + /** + * Creates a shallow clone of this ViewMedia instance. + * + * This is needed because Lit components use reference equality (===) to + * detect property changes. When we mutate an item's state (e.g., + * setReviewed), the reference doesn't change, so Lit doesn't re-render. By + * cloning the item before mutation and replacing it in the QueryResults, we + * ensure Lit sees a new reference and updates the UI. + */ + public clone(): this { + return clone(this); + } } export interface EventViewMedia extends ViewMedia { @@ -96,6 +123,12 @@ export interface RecordingViewMedia extends ViewMedia { getEventCount(): number | null; } +export interface ReviewViewMedia extends ViewMedia { + getWhat(): string[] | null; + isReviewed(): boolean | null; + setReviewed(reviewed: boolean): void; +} + interface ViewFolderParameters { icon?: string | null; id?: string | null; @@ -105,14 +138,20 @@ interface ViewFolderParameters { export class ViewFolder { private _folder: FolderConfig; + private _path: readonly FolderPathComponent[]; private _icon: string | null; private _id: string | null; private _title: string | null; private _thumbnail: string | null; - constructor(folder: FolderConfig, params?: ViewFolderParameters) { + constructor( + folder: FolderConfig, + path: readonly FolderPathComponent[], + params?: ViewFolderParameters, + ) { this._folder = folder; + this._path = path; this._icon = params?.icon ?? null; this._id = params?.id ?? null; @@ -123,21 +162,35 @@ export class ViewFolder { public getFolder(): FolderConfig { return this._folder; } + public getPath(): readonly FolderPathComponent[] { + return this._path; + } public getID(): string | null { return this._id; } public getTitle(): string | null { return this._title; } + public getDescription(): string | null { + return null; + } public getThumbnail(): string | null { return this._thumbnail; } public getIcon(): string | null { return this._icon; } + public getSeverity(): Severity | null { + return null; + } public isFavorite(): boolean | null { return null; } + + /** See ViewMedia.clone() for explanation. */ + public clone(): this { + return clone(this); + } } export type ViewItem = ViewMedia | ViewFolder; diff --git a/src/view/query-classifier.ts b/src/view/query-classifier.ts deleted file mode 100644 index aad2fc11..00000000 --- a/src/view/query-classifier.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { EventMediaQuery, FolderViewQuery, Query, RecordingMediaQuery } from './query'; - -export type QueryType = 'event' | 'recording' | 'folder'; -type MediaType = 'clips' | 'snapshots' | 'recordings'; - -export class QueryClassifier { - public static isEventQuery(query?: Query | null): query is EventMediaQuery { - return query instanceof EventMediaQuery; - } - - public static isRecordingQuery(query?: Query | null): query is RecordingMediaQuery { - return query instanceof RecordingMediaQuery; - } - - public static isMediaQuery( - query?: Query | null, - ): query is EventMediaQuery | RecordingMediaQuery { - return this.isEventQuery(query) || this.isRecordingQuery(query); - } - - public static isFolderQuery(query?: Query | null): query is FolderViewQuery { - return query instanceof FolderViewQuery; - } - - public static isClipsQuery(query?: Query | null): boolean { - return ( - this.isEventQuery(query) && !!query?.getQuery()?.every((query) => query.hasClip) - ); - } - - public static isSnapshotQuery(query?: Query | null): boolean { - return ( - this.isEventQuery(query) && - !!query?.getQuery()?.every((query) => query.hasSnapshot) - ); - } - - public static getQueryType(query?: Query | null): QueryType | null { - return this.isEventQuery(query) - ? 'event' - : this.isRecordingQuery(query) - ? 'recording' - : this.isFolderQuery(query) - ? 'folder' - : null; - } - - public static getMediaType(query?: Query | null): MediaType | null { - return this.isClipsQuery(query) - ? 'clips' - : this.isSnapshotQuery(query) - ? 'snapshots' - : this.isRecordingQuery(query) - ? 'recordings' - : null; - } -} diff --git a/src/view/query-results.ts b/src/view/query-results.ts index 428aa6af..d5959520 100644 --- a/src/view/query-results.ts +++ b/src/view/query-results.ts @@ -86,6 +86,57 @@ class ResultSlice { this.selectIndex(resultIndex); } } + + /** + * Remove an item from this slice, preserving selection where possible. + * If the selected item is removed, selects the previous item. + * @param item The item to remove. + * @returns true if the item was found and removed, false otherwise. + */ + public removeItem(item: ViewItem): boolean { + const index = this._results.indexOf(item); + if (index === -1) { + return false; + } + + // Copy-on-write: Create a new array reference only when modifying + this._results = [...this._results]; + this._results.splice(index, 1); + + // Adjust selection: if removed was selected, clamp to valid range; if after, decrement + if (this._selectedIndex !== null && this._selectedIndex >= index) { + if (this._selectedIndex === index) { + // Removed the selected item: clamp to new valid range or null if empty + this._selectedIndex = + this._results.length > 0 ? Math.min(index, this._results.length - 1) : null; + } else { + // Removed an item before the selected one: decrement + this._selectedIndex = this._selectedIndex - 1; + } + } + return true; + } + + /** + * Replace an item in this slice with a new item (e.g., a clone with updated state). + * Selection is preserved if the replaced item was selected. + * @param oldItem The item to replace. + * @param newItem The new item to insert in its place. + * @returns true if the item was found and replaced, false otherwise. + */ + public replaceItem(oldItem: ViewItem, newItem: ViewItem): boolean { + const index = this._results.indexOf(oldItem); + if (index === -1) { + return false; + } + + // When an item is replaced, we create a new array so that users can simply + // compare the results objects to determine equality (e.g. trigger Lit + // rendering for thumbnails that are marked as reviewed). + this._results = [...this._results]; + this._results[index] = newItem; + return true; + } } interface ResultSliceSelectionCriteria { @@ -142,6 +193,44 @@ export class QueryResults { return copy; } + /** + * Remove a specific item from the results. + * Note: This mutates the current instance. Use clone() first if needed. + * @param item The item to remove from results. + * @returns This QueryResults instance for chaining. + */ + public removeItem(item: ViewItem): QueryResults { + if (!this._main.removeItem(item)) { + return this; + } + + // Also remove from the relevant camera slice + const cameraID = ViewItemClassifier.isMedia(item) ? item.getCameraID() : null; + if (cameraID) { + this._cameras.get(cameraID)?.removeItem(item); + } + return this; + } + + /** + * Replace an item with a new item (e.g., a clone with updated state). + * Note: This mutates the current instance. Use clone() first if needed. + * @param oldItem The item to replace. + * @param newItem The new item to insert in its place. + * @returns This QueryResults instance for chaining. + */ + public replaceItem(oldItem: ViewItem, newItem: ViewItem): QueryResults { + if (!this._main.replaceItem(oldItem, newItem)) { + return this; + } + + const cameraID = ViewItemClassifier.isMedia(oldItem) ? oldItem.getCameraID() : null; + if (cameraID) { + this._cameras.get(cameraID)?.replaceItem(oldItem, newItem); + } + return this; + } + public isSupersetOf(that: QueryResults): boolean { const thisItemIDs = new Set(this._main.getResults()?.map((item) => item.getID())); const thatItemIDs = new Set(that._main.getResults()?.map((item) => item.getID())); diff --git a/src/view/query.ts b/src/view/query.ts deleted file mode 100644 index be7a8f68..00000000 --- a/src/view/query.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { cloneDeep, isEqual, uniqWith } from 'lodash-es'; -import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types.js'; -import { FolderQuery } from '../card-controller/folders/types.js'; -import { setify } from '../utils/basic.js'; - -export type MediaQueries = EventMediaQuery | RecordingMediaQuery; -export type Query = MediaQueries | FolderViewQuery; - -class ViewQuery { - protected _query: T | null = null; - - public constructor(query?: T) { - if (query) { - this._query = query; - } - } - - public getQuery(): T | null { - return this._query; - } - - public setQuery(query: T): this { - this._query = query; - return this; - } - - public clone(): this { - return cloneDeep(this); - } - - public isEqual(that: Query): boolean { - return isEqual(this._query, that.getQuery()); - } -} - -class MediaQueryBase extends ViewQuery { - public getQueryCameraIDs(): Set | null { - if (!this._query) { - return null; - } - const cameraIDs: Set = new Set(); - this._query.forEach((query) => - [...query.cameraIDs].forEach((cameraID) => cameraIDs.add(cameraID)), - ); - return cameraIDs; - } - - public setQueryCameraIDs(cameraIDs: string | Set): this { - if (!this._query) { - return this; - } - const rewrittenQueries: T[] = []; - this._query.forEach((query) => - rewrittenQueries.push({ ...query, cameraIDs: setify(cameraIDs) }), - ); - this._query = uniqWith(rewrittenQueries, isEqual); - return this; - } - - public hasQueriesForCameraIDs(cameraIDs: Set) { - for (const cameraID of cameraIDs) { - if (!this._query?.some((query) => query.cameraIDs.has(cameraID))) { - return false; - } - } - return true; - } - - public isSupersetOf(that: MediaQueries): boolean { - // Queries are typically a single item, so quadratic complexity here is - // likely still a lot better than going to the network for a new set of - // query results. - for (const thatQuery of that.getQuery() ?? []) { - let haveMatch = false; - for (const thisQuery of this._query ?? []) { - // Compare the query except the times, and then separately compare the - // times taking into account whether source time is larger than target - // time. - if ( - isEqual( - { - ...thisQuery, - end: null, - start: null, - }, - { ...thatQuery, end: null, start: null }, - ) && - ((!thisQuery.start && !thatQuery.start) || - (thisQuery.start && - thatQuery.start && - thisQuery.start <= thatQuery.start)) && - ((!thisQuery.end && !thatQuery.end) || - (thisQuery.end && thatQuery.end && thisQuery.end >= thatQuery.end)) - ) { - haveMatch = true; - break; - } - } - - if (!haveMatch) { - return false; - } - } - return true; - } -} - -export class EventMediaQuery extends MediaQueryBase { - public convertToClipsQueries(): this { - for (const query of this._query ?? []) { - delete query.hasSnapshot; - query.hasClip = true; - } - return this; - } -} - -export class RecordingMediaQuery extends MediaQueryBase {} - -export class FolderViewQuery extends ViewQuery {} diff --git a/src/view/unified-query-builder.ts b/src/view/unified-query-builder.ts new file mode 100644 index 00000000..1398467f --- /dev/null +++ b/src/view/unified-query-builder.ts @@ -0,0 +1,556 @@ +import { NonEmptyTuple } from 'type-fest'; +import { CameraManager } from '../camera-manager/manager'; +import { + CapabilitySearchKeys, + EventQuery, + QueryType, + RecordingQuery, + ReviewQuery, +} from '../camera-manager/types'; +import { FoldersManager } from '../card-controller/folders/manager'; +import { FolderPathComponent, FolderQuery } from '../card-controller/folders/types'; +import { CameraMediaType } from '../config/schema/cameras'; +import { FolderConfig } from '../config/schema/folders'; +import { QuerySource } from '../query-source.js'; +import { VIEW_MEDIA_TYPES, ViewMediaType } from '../types'; +import { arrayify } from '../utils/basic'; +import { QueryNode, UnifiedQuery } from '../view/unified-query'; + +interface MediaQueryBuildOptions { + start?: Date; + end?: Date; + limit?: number; +} + +interface FilterQueryBuildOptions extends MediaQueryBuildOptions { + favorite?: boolean; + tags?: Set; + what?: Set; + where?: Set; + reviewed?: boolean; +} + +interface QueryLimitOptions { + limit?: number; +} + +export interface MediaTypeSpec { + mediaType: 'events' | 'recordings' | 'reviews' | 'folder'; + eventsSubtype?: 'clips' | 'snapshots'; +} + +export const MediaTypeSpec = { + clips: (): MediaTypeSpec => ({ mediaType: 'events', eventsSubtype: 'clips' }), + events: (): MediaTypeSpec => ({ mediaType: 'events' }), + folder: (): MediaTypeSpec => ({ mediaType: 'folder' }), + recordings: (): MediaTypeSpec => ({ mediaType: 'recordings' }), + reviews: (): MediaTypeSpec => ({ mediaType: 'reviews' }), + snapshots: (): MediaTypeSpec => ({ mediaType: 'events', eventsSubtype: 'snapshots' }), +} as const; + +/** + * UnifiedQueryBuilder builds UnifiedQuery objects containing QueryNode[]. This + * is the single place where UI concepts (like "clips") are translated to strict + * data queries (EventQuery with hasClip: true). + * + * Related: UnifiedQueryRunner routes them to managers. + * + * Note on code coverage: Throughout this class, _buildBaseQueryNode and similar + * methods return null only when cameraIDs is empty. Public methods guard + * against empty cameraIDs before calling these internal methods, making the + * null branches unreachable. Istanbul ignore comments reference this note. + */ +export class UnifiedQueryBuilder { + private _cameraManager: CameraManager; + private _foldersManager: FoldersManager; + + constructor(cameraManager: CameraManager, foldersManager: FoldersManager) { + this._cameraManager = cameraManager; + this._foldersManager = foldersManager; + } + + // ========================================================================= + // Simple Query Builders + // ========================================================================= + + public buildClipsQuery( + cameraIDs: Set, + options?: MediaQueryBuildOptions, + ): UnifiedQuery | null { + return this._buildEventsQuery(cameraIDs, { hasClip: true }, options); + } + + public buildSnapshotsQuery( + cameraIDs: Set, + options?: MediaQueryBuildOptions, + ): UnifiedQuery | null { + return this._buildEventsQuery(cameraIDs, { hasSnapshot: true }, options); + } + + public buildEventsQuery( + cameraIDs: Set, + options?: MediaQueryBuildOptions, + ): UnifiedQuery | null { + return this._buildEventsQuery(cameraIDs, {}, options); + } + + private _buildEventsQuery( + cameraIDs: Set, + eventOptions?: { hasClip?: boolean; hasSnapshot?: boolean }, + options?: MediaQueryBuildOptions, + ): UnifiedQuery | null { + const query = this._addNode( + new UnifiedQuery(), + this._buildEventQueryNode(cameraIDs, eventOptions, options), + ); + return query.hasNodes() ? query : null; + } + + private _addNode( + query: UnifiedQuery, + nodes?: QueryNode | QueryNode[] | null, + ): UnifiedQuery { + if (nodes) { + arrayify(nodes).forEach((node) => query.addNode(node)); + } + return query; + } + + private _buildEventQueryNode( + cameraIDs: Set, + eventOptions?: { hasClip?: boolean; hasSnapshot?: boolean }, + options?: MediaQueryBuildOptions, + ): EventQuery | null { + return this._buildBaseQueryNode(QueryType.Event, cameraIDs, options, eventOptions); + } + + public buildRecordingsQuery( + cameraIDs: Set, + options?: MediaQueryBuildOptions, + ): UnifiedQuery | null { + if (!cameraIDs.size) { + return null; + } + + const query = this._addNode( + new UnifiedQuery(), + this._buildRecordingsQueryNode(cameraIDs, options), + ); + /* istanbul ignore next: see class note on code coverage -- @preserve */ + return query.hasNodes() ? query : null; + } + + private _buildRecordingsQueryNode( + cameraIDs: Set, + options?: MediaQueryBuildOptions, + ): RecordingQuery | null { + return this._buildBaseQueryNode(QueryType.Recording, cameraIDs, options); + } + + public buildReviewsQuery( + cameraIDs: Set, + options?: MediaQueryBuildOptions & { reviewed?: boolean }, + ): UnifiedQuery | null { + const query = this._addNode( + new UnifiedQuery(), + this._buildReviewsQueryNode(cameraIDs, options), + ); + return query.hasNodes() ? query : null; + } + + private _buildReviewsQueryNode( + cameraIDs: Set, + options?: MediaQueryBuildOptions & { reviewed?: boolean }, + ): ReviewQuery | null { + return this._buildBaseQueryNode(QueryType.Review, cameraIDs, options, { + reviewed: options?.reviewed, + }); + } + + private _buildBaseQueryNode( + type: QueryType.Event, + cameraIDs: Set, + options?: MediaQueryBuildOptions, + extraProps?: { hasClip?: boolean; hasSnapshot?: boolean }, + ): EventQuery | null; + private _buildBaseQueryNode( + type: QueryType.Recording, + cameraIDs: Set, + options?: MediaQueryBuildOptions, + ): RecordingQuery | null; + private _buildBaseQueryNode( + type: QueryType.Review, + cameraIDs: Set, + options?: MediaQueryBuildOptions, + extraProps?: { reviewed?: boolean }, + ): ReviewQuery | null; + private _buildBaseQueryNode( + type: QueryType.Event | QueryType.Recording | QueryType.Review, + cameraIDs: Set, + options?: MediaQueryBuildOptions, + extraProps?: { hasClip?: boolean; hasSnapshot?: boolean; reviewed?: boolean }, + ): EventQuery | RecordingQuery | ReviewQuery | null { + if (!cameraIDs.size) { + return null; + } + + return { + source: QuerySource.Camera, + type, + cameraIDs, + ...this._mergeDefaultsForCameras(cameraIDs, type), + ...this._extractCommonOptions(options), + ...extraProps, + }; + } + + // ========================================================================= + // Filter Query Builders + // ========================================================================= + + public getAllMediaCapableCameraIDs(): Set { + return this._cameraManager.getStore().getCameraIDsWithCapability({ + anyCapabilities: ['clips', 'snapshots', 'recordings', 'reviews'], + }); + } + + public buildFilterQuery( + cameraIDs: Set | null, + mediaTypes: Set | null, + options?: FilterQueryBuildOptions, + ): UnifiedQuery | null { + const query = new UnifiedQuery(); + + // Default to all cameras if none specified + const effectiveCameraIDs = cameraIDs?.size + ? cameraIDs + : this.getAllMediaCapableCameraIDs(); + + // Default to all media types if none specified + const effectiveMediaTypes = mediaTypes?.size + ? mediaTypes + : new Set(VIEW_MEDIA_TYPES); + + // Build camera-based media queries (when cameras are available) + if (effectiveCameraIDs.size) { + for (const mediaType of effectiveMediaTypes) { + const node = this._buildFilterQueryNode(mediaType, effectiveCameraIDs, options); + /* istanbul ignore next: see class note on code coverage -- @preserve */ + if (node) { + query.addNode(node); + } + } + } + + /* istanbul ignore next: see class note on code coverage -- @preserve */ + return query.hasNodes() ? query : null; + } + + private _buildFilterQueryNode( + mediaType: ViewMediaType, + cameraIDs: Set, + options?: FilterQueryBuildOptions, + ): EventQuery | RecordingQuery | ReviewQuery | null { + const filterProps = { + ...(options?.favorite !== undefined && { favorite: options.favorite }), + ...(options?.tags && { tags: options.tags }), + ...(options?.what && { what: options.what }), + ...(options?.where && { where: options.where }), + ...(options?.reviewed !== undefined && { reviewed: options.reviewed }), + }; + + switch (mediaType) { + case 'clips': { + const node = this._buildBaseQueryNode(QueryType.Event, cameraIDs, options, { + hasClip: true, + }); + /* istanbul ignore next: see class note on code coverage -- @preserve */ + return node ? { ...node, ...filterProps } : null; + } + case 'snapshots': { + const node = this._buildBaseQueryNode(QueryType.Event, cameraIDs, options, { + hasSnapshot: true, + }); + /* istanbul ignore next: see class note on code coverage -- @preserve */ + return node ? { ...node, ...filterProps } : null; + } + case 'recordings': { + const node = this._buildBaseQueryNode(QueryType.Recording, cameraIDs, options); + /* istanbul ignore next: see class note on code coverage -- @preserve */ + return node ? { ...node, ...filterProps } : null; + } + case 'reviews': { + const node = this._buildBaseQueryNode(QueryType.Review, cameraIDs, options, { + reviewed: options?.reviewed, + }); + /* istanbul ignore next: see class note on code coverage -- @preserve */ + return node ? { ...node, ...filterProps } : null; + } + } + } + + // ========================================================================= + // Folder Query Builders + // ========================================================================= + + public buildFolderQueryWithPath( + folder: FolderConfig, + path: NonEmptyTuple, + options?: QueryLimitOptions, + ): UnifiedQuery { + const query = new UnifiedQuery(); + const folderQuery: FolderQuery = { + source: QuerySource.Folder, + folder, + path, + ...(options?.limit !== undefined && { limit: options.limit }), + }; + query.addNode(folderQuery); + return query; + } + + public buildDefaultFolderQuery( + folderID?: string, + options?: QueryLimitOptions, + ): UnifiedQuery | null { + const query = new UnifiedQuery(); + this._addNode(query, this._buildFolderQueryNode(folderID, options)); + return query.hasNodes() ? query : null; + } + + private _buildFolderQueryNodesForCameras( + cameraIDs: Set, + options?: QueryLimitOptions, + ): QueryNode[] { + const nodes: QueryNode[] = []; + for (const cameraID of cameraIDs) { + const mediaConfig = this._cameraManager + .getStore() + .getCameraConfig(cameraID)?.media; + for (const folderID of mediaConfig?.folders ?? [undefined]) { + const node = this._buildFolderQueryNode(folderID, options); + if (node) { + nodes.push(node); + } + } + } + return nodes; + } + + private _buildFolderQueryNode( + folderID?: string, + options?: QueryLimitOptions, + ): QueryNode | null { + const folder = this._foldersManager.getFolder(folderID); + const params = folder && this._foldersManager.getDefaultQueryParameters(folder); + return params + ? { + ...params, + ...options, + } + : null; + } + + // ========================================================================= + // Default Query Builders + // ========================================================================= + + public buildDefaultCameraQuery( + cameraID?: string, + options?: QueryLimitOptions, + ): UnifiedQuery | null { + const cameraIDs = cameraID + ? this._cameraManager.getStore().getAllDependentCameras(cameraID) + : this._cameraManager.getStore().getCameraIDs(); + + const query = new UnifiedQuery(); + for (const cameraID of cameraIDs) { + this._addNode(query, this._buildDefaultCameraQueryNodes(cameraID, options)); + } + return query.hasNodes() ? query : null; + } + + private _buildDefaultCameraQueryNodes( + cameraID: string, + options?: QueryLimitOptions, + ): QueryNode | QueryNode[] | null { + const mediaConfig = this._cameraManager.getStore().getCameraConfig(cameraID)?.media; + const spec = this._resolveMediaTypeSpec( + cameraID, + mediaConfig?.type, + mediaConfig?.events_type, + ); + if (!spec) { + return null; + } + + return this._buildQueryNodesCapabilityUnchecked(spec, new Set([cameraID]), { + limit: options?.limit, + }); + } + + // ========================================================================= + // Advanced Query Builders + // ========================================================================= + + public buildCameraMediaQuery( + spec: MediaTypeSpec, + options?: QueryLimitOptions & { + cameraID?: string; + }, + ): UnifiedQuery | null { + let neededCapability: CapabilitySearchKeys; + switch (spec.mediaType) { + case 'events': + switch (spec.eventsSubtype) { + case 'clips': + case 'snapshots': + neededCapability = spec.eventsSubtype; + break; + default: + neededCapability = { anyCapabilities: ['clips', 'snapshots'] }; + break; + } + break; + case 'recordings': + case 'reviews': + neededCapability = spec.mediaType; + break; + case 'folder': + // Folders are handled separately by buildDefaultFolderQuery. + return null; + } + + const cameraIDs = options?.cameraID + ? this._cameraManager + .getStore() + .getAllDependentCameras(options.cameraID, neededCapability) + : this._cameraManager.getStore().getCameraIDsWithCapability(neededCapability); + + const query = new UnifiedQuery(); + this._addNode( + query, + this._buildQueryNodesCapabilityUnchecked(spec, cameraIDs, options), + ); + return query.hasNodes() ? query : null; + } + + private _resolveMediaTypeSpec( + cameraID: string, + type?: CameraMediaType, + eventsType?: 'clips' | 'snapshots' | 'all', + ): MediaTypeSpec | null { + const capabilities = this._cameraManager.getCameraCapabilities(cameraID); + if (!capabilities) { + return null; + } + + const hasReviews = capabilities.has('reviews'); + const hasClips = capabilities.has('clips'); + const hasSnapshots = capabilities.has('snapshots'); + const hasRecordings = capabilities.has('recordings'); + + if (!type || type === 'auto') { + if (hasReviews) { + return MediaTypeSpec.reviews(); + } + if (hasClips) { + return MediaTypeSpec.clips(); + } + if (hasSnapshots) { + return MediaTypeSpec.snapshots(); + } + if (hasRecordings) { + return MediaTypeSpec.recordings(); + } + return null; + } + + switch (type) { + case 'recordings': + return hasRecordings ? MediaTypeSpec.recordings() : null; + case 'reviews': + return hasReviews ? MediaTypeSpec.reviews() : null; + case 'folder': + return MediaTypeSpec.folder(); + case 'events': + if (eventsType === 'all' && hasClips && hasSnapshots) { + return MediaTypeSpec.events(); + } + if ((eventsType === 'all' || eventsType === 'clips') && hasClips) { + return MediaTypeSpec.clips(); + } + if ((eventsType === 'all' || eventsType === 'snapshots') && hasSnapshots) { + return MediaTypeSpec.snapshots(); + } + return null; + } + } + + private _buildQueryNodesCapabilityUnchecked( + spec: MediaTypeSpec, + cameraIDs: Set, + options?: QueryLimitOptions, + ): QueryNode | QueryNode[] | null { + switch (spec.mediaType) { + case 'events': + switch (spec.eventsSubtype) { + case 'clips': + return this._buildEventQueryNode(cameraIDs, { hasClip: true }, options); + case 'snapshots': + return this._buildEventQueryNode(cameraIDs, { hasSnapshot: true }, options); + default: + return this._buildEventQueryNode(cameraIDs, {}, options); + } + case 'recordings': + return this._buildRecordingsQueryNode(cameraIDs, options); + case 'reviews': + return this._buildReviewsQueryNode(cameraIDs, options); + case 'folder': { + return this._buildFolderQueryNodesForCameras(cameraIDs, options); + } + } + } + + // ========================================================================= + // Private Utility Helpers + // ========================================================================= + + private _extractCommonOptions(options?: MediaQueryBuildOptions): { + start?: Date; + end?: Date; + limit?: number; + } { + return { + ...(options?.start && { start: options.start }), + ...(options?.end && { end: options.end }), + ...(options?.limit !== undefined && { limit: options.limit }), + }; + } + + private _mergeDefaultsForCameras( + cameraIDs: Set, + queryType: QueryType, + ): { what?: Set; where?: Set } { + const what: string[] = []; + const where: string[] = []; + + for (const cameraID of cameraIDs) { + const defaults = this._cameraManager.getDefaultQueryParameters( + cameraID, + queryType, + ); + if (defaults?.what) { + what.push(...defaults.what); + } + if (defaults?.where) { + where.push(...defaults.where); + } + } + + return { + ...(what.length && { what: new Set(what) }), + ...(where.length && { where: new Set(where) }), + }; + } +} diff --git a/src/view/unified-query-runner.ts b/src/view/unified-query-runner.ts new file mode 100644 index 00000000..c04df52c --- /dev/null +++ b/src/view/unified-query-runner.ts @@ -0,0 +1,114 @@ +import { CameraManager } from '../camera-manager/manager'; +import { FoldersManager } from '../card-controller/folders/manager'; +import { ConditionStateManagerReadonlyInterface } from '../conditions/types'; +import { ViewItem } from '../view/item'; +import { UnifiedQuery } from '../view/unified-query'; + +interface QueryRunnerOptions { + useCache?: boolean; +} + +/** + * UnifiedQueryRunner routes UnifiedQuery nodes to the appropriate managers. + */ +export class UnifiedQueryRunner { + private _cameraManager: CameraManager; + private _foldersManager: FoldersManager; + private _conditionStateManager: ConditionStateManagerReadonlyInterface; + + constructor( + cameraManager: CameraManager, + foldersManager: FoldersManager, + conditionStateManager: ConditionStateManagerReadonlyInterface, + ) { + this._cameraManager = cameraManager; + this._foldersManager = foldersManager; + this._conditionStateManager = conditionStateManager; + } + + public async execute( + query: UnifiedQuery, + options?: QueryRunnerOptions, + ): Promise { + const allItems: ViewItem[] = []; + + // Execute media queries + const mediaQueries = query.getMediaQueries(); + if (mediaQueries.length > 0) { + const items = await this._cameraManager.executeMediaQueries(mediaQueries, { + useCache: options?.useCache, + }); + allItems.push(...(items ?? [])); + } + + // Execute folder queries + const folderQueries = query.getFolderQueries(); + for (const folderQuery of folderQueries) { + const items = await this._foldersManager.expandFolder( + folderQuery, + this._conditionStateManager.getState(), + { useCache: options?.useCache }, + ); + allItems.push(...(items ?? [])); + } + + return allItems; + } + + public areResultsFresh(resultsTimestamp: Date, query: UnifiedQuery): boolean { + const mediaQueries = query.getMediaQueries(); + if ( + mediaQueries.length > 0 && + !this._cameraManager.areMediaQueriesResultsFresh(resultsTimestamp, mediaQueries) + ) { + return false; + } + + const folderQueries = query.getFolderQueries(); + for (const folderQuery of folderQueries) { + if (!this._foldersManager.areResultsFresh(resultsTimestamp, folderQuery)) { + return false; + } + } + + return true; + } + + /** + * Extend a query by fetching additional media in a direction (earlier/later). + */ + public async extend( + query: UnifiedQuery, + existingResults: ViewItem[], + direction: 'earlier' | 'later', + options?: QueryRunnerOptions, + ): Promise<{ query: UnifiedQuery; results: ViewItem[] } | null> { + const mediaQueries = query.getMediaQueries(); + const nonExtendableQueries = query.getNonMediaQueries(); + + if (mediaQueries.length === 0) { + return null; + } + + const extension = await this._cameraManager.extendMediaQueries( + mediaQueries, + existingResults, + direction, + { useCache: options?.useCache }, + ); + + if (!extension) { + return null; + } + + const extendedQuery = new UnifiedQuery(); + for (const mediaQuery of extension.queries) { + extendedQuery.addNode(mediaQuery); + } + for (const node of nonExtendableQueries) { + extendedQuery.addNode(node); + } + + return { query: extendedQuery, results: extension.results }; + } +} diff --git a/src/view/unified-query-transformer.ts b/src/view/unified-query-transformer.ts new file mode 100644 index 00000000..37aab9cd --- /dev/null +++ b/src/view/unified-query-transformer.ts @@ -0,0 +1,57 @@ +import { CameraQueryClassifier } from '../camera-manager/manager'; +import { QuerySource } from '../query-source'; +import { UnifiedQuery } from './unified-query'; + +interface RebuildOptions { + start?: Date; + end?: Date; + limit?: number; +} + +/** + * Static utility methods for cloning and transforming existing UnifiedQuery + * objects. Unlike UnifiedQueryBuilder, these don't require state (e.g. a + * CameraManager). + */ +export class UnifiedQueryTransformer { + static stripTimeRange(query: UnifiedQuery): UnifiedQuery { + const nodes = query.getNodes().map((node) => { + if (node.source !== QuerySource.Camera) { + return node; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { start, end, ...rest } = node; + return rest; + }); + return new UnifiedQuery(nodes); + } + + static rebuildQuery(query: UnifiedQuery, options: RebuildOptions): UnifiedQuery { + const commonOptions = { + ...(options?.start && { start: options.start }), + ...(options?.end && { end: options.end }), + ...(options?.limit !== undefined && { limit: options.limit }), + }; + const nodes = query.getNodes().map((node) => { + if (node.source === QuerySource.Camera) { + return { + ...node, + ...commonOptions, + }; + } + return node; + }); + return new UnifiedQuery(nodes); + } + + static convertToClips(query: UnifiedQuery): UnifiedQuery { + const nodes = query + .getNodes() + .map((node) => + node.source === QuerySource.Camera && CameraQueryClassifier.isEventQuery(node) + ? { ...node, hasClip: true, hasSnapshot: undefined } + : node, + ); + return new UnifiedQuery(nodes); + } +} diff --git a/src/view/unified-query.ts b/src/view/unified-query.ts new file mode 100644 index 00000000..8da141a9 --- /dev/null +++ b/src/view/unified-query.ts @@ -0,0 +1,162 @@ +import cloneDeep from 'lodash-es/cloneDeep.js'; +import isEqual from 'lodash-es/isEqual.js'; +import omit from 'lodash-es/omit.js'; +import { EventQuery, MediaQuery, QueryType } from '../camera-manager/types.js'; +import { FolderQuery } from '../card-controller/folders/types.js'; +import { QuerySource } from '../query-source.js'; +import { ViewMediaType } from '../types.js'; + +export type QueryNode = MediaQuery | FolderQuery; + +export class UnifiedQuery { + private _nodes: QueryNode[]; + + constructor(nodes?: QueryNode[]) { + this._nodes = nodes ? [...nodes] : []; + } + + public addNode(node: QueryNode): this { + this._nodes.push(node); + return this; + } + + public getNodes(): QueryNode[] { + return this._nodes; + } + + public getNodeCount(): number { + return this._nodes.length; + } + + public hasNodes(): boolean { + return this._nodes.length > 0; + } + + public getMediaQueries(options?: { + cameraID?: string; + type?: QueryType; + }): T[] { + return this._nodes.filter((node): node is T => { + if (!this._isMediaQuery(node)) { + return false; + } + if (options?.cameraID && !node.cameraIDs.has(options.cameraID)) { + return false; + } + if (options?.type && node.type !== options.type) { + return false; + } + return true; + }); + } + + public getFolderQueries(folderID?: string): FolderQuery[] { + return this._nodes.filter( + (node): node is FolderQuery => + this._isFolderQuery(node) && (!folderID || node.folder.id === folderID), + ); + } + + public getNonMediaQueries(): QueryNode[] { + return this._nodes.filter((node) => !this._isMediaQuery(node)); + } + + public hasMediaQueriesOfType(type: QueryType): boolean { + return this._nodes.some((node) => this._isMediaQuery(node) && node.type === type); + } + + public getAllCameraIDs(): Set { + const cameraIDs = new Set(); + for (const node of this._nodes) { + if (this._isMediaQuery(node)) { + for (const id of node.cameraIDs) { + cameraIDs.add(id); + } + } + } + return cameraIDs; + } + + public getAllMediaTypes(): Set { + const types = new Set(); + for (const node of this._nodes) { + if (this._isMediaQuery(node)) { + if (node.type === QueryType.Event) { + const eventQuery = node as EventQuery; + if (eventQuery.hasClip) { + types.add('clips'); + } + if (eventQuery.hasSnapshot) { + types.add('snapshots'); + } + } else if (node.type === QueryType.Recording) { + types.add('recordings'); + } else if (node.type === QueryType.Review) { + types.add('reviews'); + } + } + } + return types; + } + + public clone(): UnifiedQuery { + return new UnifiedQuery(cloneDeep(this._nodes)); + } + + public isEqual(that: UnifiedQuery): boolean { + return isEqual(this._nodes, that._nodes); + } + + /** + * Check if this query is a superset of another query. + * A query is a superset if it covers at least all the queries of the other. + * For media queries, this means same cameras/type and time range encompasses. + */ + public isSupersetOf(that: UnifiedQuery): boolean { + const nodeCovers = (thisNode: QueryNode, thatNode: QueryNode): boolean => { + if (this._isMediaQuery(thatNode) && this._isMediaQuery(thisNode)) { + const stripTimeRange = (query: MediaQuery) => omit(query, ['start', 'end']); + return ( + isEqual(stripTimeRange(thisNode), stripTimeRange(thatNode)) && + timeRangeCovers(thisNode, thatNode) + ); + } + return isEqual(thisNode, thatNode); + }; + + const timeRangeCovers = ( + source: { start?: Date; end?: Date }, + target: { start?: Date; end?: Date }, + ): boolean => { + const boundaryCovers = ( + compare: (s: Date, t: Date) => boolean, + sourceBound?: Date, + targetBound?: Date, + ): boolean => { + if (!targetBound) { + // Unbounded target requires unbounded source + return !sourceBound; + } + // Bounded target: source must be unbounded or extend at least as far + return !sourceBound || compare(sourceBound, targetBound); + }; + + return ( + boundaryCovers((s, t) => s <= t, source.start, target.start) && + boundaryCovers((s, t) => s >= t, source.end, target.end) + ); + }; + + return that._nodes.every((thatNode) => + this._nodes.some((thisNode) => nodeCovers(thisNode, thatNode)), + ); + } + + private _isMediaQuery(node: QueryNode): node is MediaQuery { + return node.source === QuerySource.Camera; + } + + private _isFolderQuery(node: QueryNode): node is FolderQuery { + return node.source === QuerySource.Folder; + } +} diff --git a/src/view/view-support.ts b/src/view/view-support.ts index 2fb76f71..14304692 100644 --- a/src/view/view-support.ts +++ b/src/view/view-support.ts @@ -32,6 +32,8 @@ export const getCameraIDsForViewName = ( case 'snapshots': case 'recording': case 'recordings': + case 'review': + case 'reviews': const options: CapabilitySearchOptions = { inclusive: viewName !== 'live', }; @@ -42,7 +44,9 @@ export const getCameraIDsForViewName = ( ? 'snapshots' : viewName === 'recording' ? 'recordings' - : viewName; + : viewName === 'review' + ? 'reviews' + : viewName; return cameraID ? cameraManager.getStore().getAllDependentCameras(cameraID, capability, options) : cameraManager.getStore().getCameraIDsWithCapability(capability, options); diff --git a/src/view/view.ts b/src/view/view.ts index 376a4e0f..5ab1d751 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -2,8 +2,9 @@ import { merge } from 'lodash-es'; import { ViewContext } from 'view'; import { AdvancedCameraCardView } from '../config/schema/common/const'; import { ViewDisplayMode } from '../config/schema/common/display'; -import { Query } from './query'; +import { ViewMediaType } from '../types'; import { QueryResults } from './query-results'; +import { UnifiedQuery } from './unified-query'; declare module 'view' { interface ViewContext { @@ -16,7 +17,7 @@ declare module 'view' { interface ViewEvolveParameters { view?: AdvancedCameraCardView; camera?: string; - query?: Query | null; + query?: UnifiedQuery | null; queryResults?: QueryResults | null; context?: ViewContext | null; displayMode?: ViewDisplayMode | null; @@ -37,7 +38,7 @@ export const mergeViewContext = ( export class View { public view: AdvancedCameraCardView; public camera: string; - public query: Query | null; + public query: UnifiedQuery | null; public queryResults: QueryResults | null; public context: ViewContext | null; public displayMode: ViewDisplayMode | null; @@ -123,10 +124,12 @@ export class View { } /** - * Determine if a view is a media gallery. + * Determine if a view is a gallery. */ - public isMediaGalleryView(): boolean { - return ['clips', 'folders', 'snapshots', 'recordings'].includes(this.view); + public isGalleryView(): boolean { + return ['clips', 'folders', 'snapshots', 'recordings', 'reviews'].includes( + this.view, + ); } /** @@ -145,19 +148,16 @@ export class View { * Determine if a view is for the media viewer. */ public isViewerView(): boolean { - return ['folder', 'media', 'clip', 'snapshot', 'recording'].includes(this.view); + return ['folder', 'media', 'clip', 'snapshot', 'recording', 'review'].includes( + this.view, + ); } public supportsMultipleDisplayModes(): boolean { return this.isViewerView() || this.is('live'); } - /** - * Get the default media type for this view if available. - * @returns Whether the default media is `clips`, `snapshots`, `recordings` or unknown - * (`null`). - */ - public getDefaultMediaType(): 'clips' | 'snapshots' | 'recordings' | null { + public getDefaultMediaType(): ViewMediaType | null { if (['clip', 'clips'].includes(this.view)) { return 'clips'; } @@ -167,6 +167,9 @@ export class View { if (['recording', 'recordings'].includes(this.view)) { return 'recordings'; } + if (['review', 'reviews'].includes(this.view)) { + return 'reviews'; + } return null; } diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts index 0d64d48a..34c7fd08 100644 --- a/tests/camera-manager/engine-factory.test.ts +++ b/tests/camera-manager/engine-factory.test.ts @@ -10,6 +10,7 @@ import { Engine } from '../../src/camera-manager/types.js'; import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; import { CardWideConfig } from '../../src/config/schema/types.js'; import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js'; +import { DeviceRegistryManager } from '../../src/ha/registry/device'; import { ResolvedMediaCache } from '../../src/ha/resolved-media.js'; import { EntityRegistryManagerMock } from '../ha/registry/entity/mock.js'; import { @@ -28,6 +29,7 @@ const createFactory = (options?: { }): CameraManagerEngineFactory => { return new CameraManagerEngineFactory( options?.entityRegistryManager ?? new EntityRegistryManagerMock(), + mock(), ); }; diff --git a/tests/camera-manager/frigate/camera.test.ts b/tests/camera-manager/frigate/camera.test.ts index 6ccd4757..e9ec5e7f 100644 --- a/tests/camera-manager/frigate/camera.test.ts +++ b/tests/camera-manager/frigate/camera.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CameraManagerEngine } from '../../../src/camera-manager/engine'; import { FrigateCamera } from '../../../src/camera-manager/frigate/camera'; -import { FrigateEventWatcher } from '../../../src/camera-manager/frigate/event-watcher'; import { FrigateEventViewMedia, FrigateRecordingViewMedia, @@ -11,7 +10,12 @@ import { getPTZInfo } from '../../../src/camera-manager/frigate/requests'; import { eventSchema, FrigateEventChange, + FrigateReviewChange, } from '../../../src/camera-manager/frigate/types'; +import { + FrigateEventWatcher, + FrigateReviewWatcher, +} from '../../../src/camera-manager/frigate/watcher'; import { ActionsExecutor } from '../../../src/card-controller/actions/types'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { PTZAction } from '../../../src/config/schema/actions/custom/ptz'; @@ -33,6 +37,16 @@ const callEventWatcherCallback = ( mock.calls[n][1].callback(event); }; +const callReviewWatcherCallback = ( + reviewWatcher: FrigateReviewWatcher, + review: FrigateReviewChange, + n = 0, +): void => { + const mock = vi.mocked(reviewWatcher.subscribe).mock; + expect(mock.calls.length).greaterThan(n); + mock.calls[n][1].callback(review); +}; + describe('FrigateCamera', () => { beforeEach(() => { vi.clearAllMocks(); @@ -51,6 +65,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(beforeConfig).toEqual(camera.getConfig()); @@ -72,6 +87,7 @@ describe('FrigateCamera', () => { entityRegistryManager: entityRegistryManager, stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }), ).rejects.toThrowError(/Could not find camera entity/); }); @@ -96,6 +112,7 @@ describe('FrigateCamera', () => { entityRegistryManager: entityRegistryManager, stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getConfig().frigate.camera_name).toBe('fnt_dr'); }); @@ -120,6 +137,7 @@ describe('FrigateCamera', () => { entityRegistryManager: entityRegistryManager, stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getConfig().frigate.camera_name).toBeUndefined(); }); @@ -144,6 +162,7 @@ describe('FrigateCamera', () => { entityRegistryManager: entityRegistryManager, stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getConfig().frigate.camera_name).toBeUndefined(); }); @@ -166,6 +185,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getCapabilities()?.has('favorite-events')).toBeTruthy(); expect(camera.getCapabilities()?.has('favorite-recordings')).toBeFalsy(); @@ -193,6 +213,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getCapabilities()?.has('favorite-events')).toBeFalsy(); expect(camera.getCapabilities()?.has('favorite-recordings')).toBeFalsy(); @@ -224,6 +245,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getCapabilities()?.has('ptz')).toBeFalsy(); @@ -253,6 +275,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getCapabilities()?.has('ptz')).toBeTruthy(); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -289,6 +312,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getCapabilities()?.has('ptz')).toBeTruthy(); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -320,6 +344,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getCapabilities()?.has('ptz')).toBeTruthy(); @@ -355,6 +380,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getCapabilities()?.has('ptz')).toBeTruthy(); @@ -504,8 +530,8 @@ describe('FrigateCamera', () => { 'front_door', { cameraID: 'front_door', - startTime: new Date('2023-01-01T10:00:00Z'), - endTime: new Date('2023-01-01T11:00:00Z'), + startTime: new Date('2023-01-01T10:00:00'), + endTime: new Date('2023-01-01T11:00:00'), events: 0, }, 'recording-id', @@ -536,7 +562,7 @@ describe('FrigateCamera', () => { cameraID: 'front_door', // Forced null for test startTime: null as unknown as Date, - endTime: new Date('2023-01-01T11:00:00Z'), + endTime: new Date('2023-01-01T11:00:00'), events: 0, }, 'recording-id', @@ -667,6 +693,9 @@ describe('FrigateCamera', () => { client_id: 'CLIENT_ID', camera_name: 'CAMERA', }, + triggers: { + events: ['events'], + }, }), mock(), ); @@ -678,6 +707,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: eventWatcher, + frigateReviewWatcher: mock(), }); expect(eventWatcher.subscribe).toBeCalledWith( hass, @@ -708,6 +738,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: eventWatcher, + frigateReviewWatcher: mock(), }); expect(eventWatcher.subscribe).not.toBeCalled(); }); @@ -733,6 +764,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: eventWatcher, + frigateReviewWatcher: mock(), }); expect(eventWatcher.subscribe).not.toBeCalled(); }); @@ -743,6 +775,9 @@ describe('FrigateCamera', () => { frigate: { client_id: 'CLIENT_ID', }, + triggers: { + events: ['events'], + }, }), mock(), ); @@ -754,6 +789,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: eventWatcher, + frigateReviewWatcher: mock(), }); expect(eventWatcher.subscribe).not.toBeCalled(); }); @@ -762,6 +798,9 @@ describe('FrigateCamera', () => { const camera = new FrigateCamera( createCameraConfig({ frigate: { camera_name: 'front_door' }, + triggers: { + events: ['events'], + }, }), mock(), ); @@ -775,6 +814,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: eventWatcher, + frigateReviewWatcher: mock(), }); expect(eventWatcher.unsubscribe).not.toBeCalled(); @@ -871,6 +911,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: eventWatcher, + frigateReviewWatcher: mock(), }); callEventWatcherCallback(eventWatcher, { @@ -922,6 +963,9 @@ describe('FrigateCamera', () => { camera_name: 'camera.front_door', zones: ['front_steps'], }, + triggers: { + events: ['events'], + }, }), mock(), { @@ -936,6 +980,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: eventWatcher, + frigateReviewWatcher: mock(), }); callEventWatcherCallback(eventWatcher, { @@ -975,6 +1020,9 @@ describe('FrigateCamera', () => { camera_name: 'camera.front_door', labels: ['person'], }, + triggers: { + events: ['events'], + }, }), mock(), { @@ -989,6 +1037,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: eventWatcher, + frigateReviewWatcher: mock(), }); callEventWatcherCallback(eventWatcher, { @@ -1025,6 +1074,9 @@ describe('FrigateCamera', () => { frigate: { camera_name: 'camera.front_door', }, + triggers: { + events: ['events'], + }, }), mock(), { @@ -1039,6 +1091,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: eventWatcher, + frigateReviewWatcher: mock(), }); callEventWatcherCallback(eventWatcher, { @@ -1065,6 +1118,666 @@ describe('FrigateCamera', () => { }); }); }); + describe('should handle reviews', () => { + it('should subscribe to reviews', async () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + client_id: 'CLIENT_ID', + camera_name: 'CAMERA', + }, + triggers: { + reviews: { + severities: ['high'], + }, + }, + }), + mock(), + ); + const hass = createHASS(); + + const reviewWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: reviewWatcher, + }); + expect(reviewWatcher.subscribe).toBeCalledWith( + hass, + expect.objectContaining({ + instanceID: 'CLIENT_ID', + }), + ); + }); + + it('should not subscribe to reviews without camera_name', async () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + client_id: 'CLIENT_ID', + // Note: no camera_name + }, + triggers: { + reviews: { + severities: ['high'], + }, + }, + }), + mock(), + ); + const hass = createHASS(); + + const reviewWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: reviewWatcher, + }); + expect(reviewWatcher.subscribe).not.toBeCalled(); + }); + + it('should not subscribe to reviews with description only (no severities)', async () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + client_id: 'CLIENT_ID', + camera_name: 'CAMERA', + }, + triggers: { + reviews: { + severities: [], + description: true, + }, + }, + }), + mock(), + ); + const hass = createHASS(); + + const reviewWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: reviewWatcher, + }); + // Severities are required - description alone is not enough + expect(reviewWatcher.subscribe).not.toBeCalled(); + }); + + describe('should call handler correctly', () => { + it('should handle review severity triggers', async () => { + const eventCallback = vi.fn(); + const camera = new FrigateCamera( + createCameraConfig({ + id: 'CAMERA_1', + frigate: { + camera_name: 'front_door', + }, + triggers: { + reviews: { + severities: ['high'], + }, + }, + }), + mock(), + { + eventCallback: eventCallback, + }, + ); + + const hass = createHASS(); + const reviewWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: reviewWatcher, + }); + + callReviewWatcherCallback(reviewWatcher, { + type: 'new', + before: { + id: '123', + camera: 'front_door', + severity: 'alert', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: {}, + }, + after: { + id: '123', + camera: 'front_door', + severity: 'alert', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: {}, + }, + }); + + expect(eventCallback).toBeCalledWith({ + type: 'new', + cameraID: 'CAMERA_1', + fidelity: 'high', + review: true, + }); + }); + + it('should handle review description triggers', async () => { + const eventCallback = vi.fn(); + const camera = new FrigateCamera( + createCameraConfig({ + id: 'CAMERA_1', + frigate: { + camera_name: 'front_door', + }, + triggers: { + reviews: { + severities: ['medium'], + description: true, + }, + }, + }), + mock(), + { + eventCallback: eventCallback, + }, + ); + + const hass = createHASS(); + const reviewWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: reviewWatcher, + }); + + callReviewWatcherCallback(reviewWatcher, { + type: 'update', + before: { + id: '123', + camera: 'front_door', + severity: 'detection', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: { + metadata: { + title: 'Old title', + }, + }, + }, + after: { + id: '123', + camera: 'front_door', + severity: 'detection', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: { + metadata: { + title: 'New title', + }, + }, + }, + }); + + expect(eventCallback).toBeCalledWith({ + type: 'update', + cameraID: 'CAMERA_1', + fidelity: 'high', + review: true, + }); + }); + + it('should handle review scene-only description triggers', async () => { + const eventCallback = vi.fn(); + const camera = new FrigateCamera( + createCameraConfig({ + id: 'CAMERA_1', + frigate: { + camera_name: 'front_door', + }, + triggers: { + reviews: { + severities: ['medium'], + description: true, + }, + }, + }), + mock(), + { + eventCallback: eventCallback, + }, + ); + + const hass = createHASS(); + const reviewWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: reviewWatcher, + }); + + callReviewWatcherCallback(reviewWatcher, { + type: 'update', + before: { + id: '123', + camera: 'front_door', + severity: 'detection', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: { + metadata: { + scene: 'Old scene', + }, + }, + }, + after: { + id: '123', + camera: 'front_door', + severity: 'detection', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: { + metadata: { + scene: 'New scene', + }, + }, + }, + }); + + expect(eventCallback).toBeCalledWith({ + type: 'update', + cameraID: 'CAMERA_1', + fidelity: 'high', + review: true, + }); + }); + + it('should filter by zones', async () => { + const eventCallback = vi.fn(); + const camera = new FrigateCamera( + createCameraConfig({ + id: 'CAMERA_1', + frigate: { + camera_name: 'front_door', + zones: ['yard'], + }, + triggers: { + reviews: { + severities: ['high'], + }, + }, + }), + mock(), + { + eventCallback: eventCallback, + }, + ); + + const hass = createHASS(); + const reviewWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: reviewWatcher, + }); + + callReviewWatcherCallback(reviewWatcher, { + type: 'new', + before: { + id: '123', + camera: 'front_door', + severity: 'alert', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: { zones: ['driveway'] }, + }, + after: { + id: '123', + camera: 'front_door', + severity: 'alert', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: { zones: ['driveway'] }, + }, + }); + + expect(eventCallback).not.toHaveBeenCalled(); + }); + + it('should filter by labels', async () => { + const eventCallback = vi.fn(); + const camera = new FrigateCamera( + createCameraConfig({ + id: 'CAMERA_1', + frigate: { + camera_name: 'front_door', + labels: ['person'], + }, + triggers: { + reviews: { + severities: ['high'], + }, + }, + }), + mock(), + { + eventCallback: eventCallback, + }, + ); + + const hass = createHASS(); + const reviewWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: reviewWatcher, + }); + + callReviewWatcherCallback(reviewWatcher, { + type: 'new', + before: { + id: '123', + camera: 'front_door', + severity: 'alert', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: { objects: ['car'] }, + }, + after: { + id: '123', + camera: 'front_door', + severity: 'alert', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: { objects: ['car'] }, + }, + }); + + expect(eventCallback).not.toHaveBeenCalled(); + }); + + it('should ignore events when camera ID is not set', async () => { + const eventCallback = vi.fn(); + const camera = new FrigateCamera( + createCameraConfig({ + // Note: No 'id' is set here + frigate: { + camera_name: 'front_door', + }, + triggers: { + reviews: { + severities: ['high'], + }, + }, + }), + mock(), + { + eventCallback: eventCallback, + }, + ); + + const hass = createHASS(); + const reviewWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: reviewWatcher, + }); + + callReviewWatcherCallback(reviewWatcher, { + type: 'new', + before: { + id: '123', + camera: 'front_door', + severity: 'alert', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: {}, + }, + after: { + id: '123', + camera: 'front_door', + severity: 'alert', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: {}, + }, + }); + + expect(eventCallback).not.toHaveBeenCalled(); + }); + }); + }); + + describe('should not trigger on non-matching reviews', () => { + it('should not trigger when review severity does not match', async () => { + const eventCallback = vi.fn(); + const camera = new FrigateCamera( + createCameraConfig({ + id: 'CAMERA_1', + frigate: { + camera_name: 'front_door', + }, + triggers: { + reviews: { + severities: ['high'], + description: false, + }, + }, + }), + mock(), + { + eventCallback: eventCallback, + }, + ); + + const hass = createHASS(); + const reviewWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: reviewWatcher, + }); + + callReviewWatcherCallback(reviewWatcher, { + type: 'new', + before: { + id: '123', + camera: 'front_door', + severity: 'detection', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: {}, + }, + after: { + id: '123', + camera: 'front_door', + severity: 'detection', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: {}, + }, + }); + + expect(eventCallback).not.toHaveBeenCalled(); + }); + + it('should not trigger on update without description change', async () => { + const eventCallback = vi.fn(); + const camera = new FrigateCamera( + createCameraConfig({ + id: 'CAMERA_1', + frigate: { + camera_name: 'front_door', + }, + triggers: { + reviews: { + severities: ['high'], + description: true, + }, + }, + }), + mock(), + { + eventCallback: eventCallback, + }, + ); + + const hass = createHASS(); + const reviewWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: reviewWatcher, + }); + + // Update event with matching severity but no description change + callReviewWatcherCallback(reviewWatcher, { + type: 'update', + before: { + id: '123', + camera: 'front_door', + severity: 'alert', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: { + metadata: { + title: 'Same title', + }, + }, + }, + after: { + id: '123', + camera: 'front_door', + severity: 'alert', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: { + metadata: { + title: 'Same title', // No change + }, + }, + }, + }); + + expect(eventCallback).not.toHaveBeenCalled(); + }); + + it('should trigger on end event with matching severity', async () => { + const eventCallback = vi.fn(); + const camera = new FrigateCamera( + createCameraConfig({ + id: 'CAMERA_1', + frigate: { + camera_name: 'front_door', + }, + triggers: { + reviews: { + severities: ['high'], + }, + }, + }), + mock(), + { + eventCallback: eventCallback, + }, + ); + + const hass = createHASS(); + const reviewWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: reviewWatcher, + }); + + callReviewWatcherCallback(reviewWatcher, { + type: 'end', + before: { + id: '123', + camera: 'front_door', + severity: 'alert', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: {}, + }, + after: { + id: '123', + camera: 'front_door', + severity: 'alert', + start_time: 123, + end_time: 456, + thumb_path: null, + has_been_reviewed: false, + data: {}, + }, + }); + + expect(eventCallback).toBeCalledWith({ + type: 'end', + cameraID: 'CAMERA_1', + fidelity: 'high', + review: true, + }); + }); + }); describe('should handle triggers', () => { const cameraEntity: Partial = { @@ -1107,6 +1820,7 @@ describe('FrigateCamera', () => { entityRegistryManager: entityRegistryManager, stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getConfig().triggers.entities).toEqual([]); @@ -1136,6 +1850,7 @@ describe('FrigateCamera', () => { entityRegistryManager: entityRegistryManager, stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']); @@ -1159,6 +1874,7 @@ describe('FrigateCamera', () => { entityRegistryManager: new EntityRegistryManagerMock(), stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getConfig().triggers.entities).toEqual([]); @@ -1185,6 +1901,7 @@ describe('FrigateCamera', () => { entityRegistryManager: entityRegistryManager, stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getConfig().triggers.entities).toEqual([]); @@ -1209,6 +1926,7 @@ describe('FrigateCamera', () => { entityRegistryManager: entityRegistryManager, stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getConfig().triggers.entities).toEqual([]); @@ -1232,6 +1950,7 @@ describe('FrigateCamera', () => { entityRegistryManager: new EntityRegistryManagerMock(), stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getConfig().triggers.entities).toEqual([]); @@ -1264,6 +1983,7 @@ describe('FrigateCamera', () => { entityRegistryManager: entityRegistryManager, stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']); @@ -1298,6 +2018,7 @@ describe('FrigateCamera', () => { entityRegistryManager: entityRegistryManager, stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']); @@ -1317,6 +2038,7 @@ describe('FrigateCamera', () => { entityRegistryManager: mock(), stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); const executor = mock(); @@ -1349,6 +2071,7 @@ describe('FrigateCamera', () => { ]), stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); const executor = mock(); @@ -1386,6 +2109,7 @@ describe('FrigateCamera', () => { ]), stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); const executor = mock(); @@ -1438,6 +2162,7 @@ describe('FrigateCamera', () => { ]), stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); const executor = mock(); @@ -1473,6 +2198,7 @@ describe('FrigateCamera', () => { ]), stateWatcher: mock(), frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), }); const executor = mock(); diff --git a/tests/camera-manager/frigate/media.test.ts b/tests/camera-manager/frigate/media.test.ts new file mode 100644 index 00000000..ddabd909 --- /dev/null +++ b/tests/camera-manager/frigate/media.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { + FrigateEventViewMedia, + FrigateReviewViewMedia, +} from '../../../src/camera-manager/frigate/media'; +import { ViewMediaType } from '../../../src/view/item'; +import { createFrigateEvent, createFrigateReview } from '../../test-utils'; + +describe('FrigateReviewViewMedia', () => { + it('should get description when scene is present', () => { + const review = createFrigateReview({ + data: { + objects: [], + zones: [], + metadata: { + scene: 'A person walking', + title: 'Title', + }, + }, + }); + const media = new FrigateReviewViewMedia( + 'camera', + review, + 'content_id', + 'thumbnail', + ); + expect(media.getDescription()).toBe('A person walking'); + }); + + it('should get null description when scene is absent', () => { + const review = createFrigateReview({ + data: { + objects: [], + zones: [], + metadata: { + title: 'Title', + // scene is absent. + }, + }, + }); + const media = new FrigateReviewViewMedia( + 'camera', + review, + 'content_id', + 'thumbnail', + ); + expect(media.getDescription()).toBeNull(); + }); +}); + +describe('FrigateEventViewMedia', () => { + it('should get description when description is present', () => { + const event = createFrigateEvent({ + data: { + description: 'A person walking', + }, + }); + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + event, + 'content_id', + 'thumbnail', + ); + expect(media.getDescription()).toBe('A person walking'); + }); + + it('should get null description when data is absent', () => { + const event = createFrigateEvent(); + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'camera', + event, + 'content_id', + 'thumbnail', + ); + expect(media.getDescription()).toBeNull(); + }); +}); diff --git a/tests/camera-manager/frigate/requests.test.ts b/tests/camera-manager/frigate/requests.test.ts index d461ae3f..740ae803 100644 --- a/tests/camera-manager/frigate/requests.test.ts +++ b/tests/camera-manager/frigate/requests.test.ts @@ -5,17 +5,22 @@ import { getPTZInfo, getRecordingSegments, getRecordingsSummary, + getReviews, retainEvent, + setReviewsReviewed, } from '../../../src/camera-manager/frigate/requests'; import { EventSummary, eventSummarySchema, FrigateEvent, frigateEventsSchema, + FrigateReview, + frigateReviewsSchema, ptzInfoSchema, recordingSegmentsSchema, recordingSummarySchema, retainResultSchema, + reviewResultSchema, } from '../../../src/camera-manager/frigate/types'; import { RecordingSegment } from '../../../src/camera-manager/types'; import { homeAssistantWSRequest } from '../../../src/ha/ws-request'; @@ -197,7 +202,7 @@ describe('frigate requests', () => { ); }); - it('should get PTZ info', async () => { + it('should get PTZInfo', async () => { const ptzInfo = [ { name: 'camera.office', @@ -219,4 +224,99 @@ describe('frigate requests', () => { true, ); }); + + it('should get reviews', async () => { + const reviews: FrigateReview[] = [ + { + id: 'review_id', + camera: 'camera', + start_time: 0, + end_time: 1, + severity: 'alert', + thumb_path: 'thumb.jpg', + data: { + objects: [], + zones: [], + }, + has_been_reviewed: false, + }, + ]; + const hass = createHASS(); + vi.mocked(homeAssistantWSRequest).mockResolvedValue(reviews); + expect( + await getReviews(hass, { + instance_id: 'clientID', + cameras: ['camera'], + labels: ['person'], + zones: ['zone'], + severity: 'alert', + after: 0, + before: 1, + limit: 10, + reviewed: false, + }), + ).toBe(reviews); + expect(homeAssistantWSRequest).toBeCalledWith( + hass, + frigateReviewsSchema, + expect.objectContaining({ + type: 'frigate/reviews/get', + instance_id: 'clientID', + cameras: ['camera'], + labels: ['person'], + zones: ['zone'], + severity: 'alert', + after: 0, + before: 1, + limit: 10, + reviewed: false, + }), + true, + ); + }); + + describe('should set reviews reviewed', async () => { + it('successfully', async () => { + vi.mocked(homeAssistantWSRequest).mockResolvedValue({ + success: true, + message: 'success', + }); + + const hass = createHASS(); + setReviewsReviewed(hass, 'clientID', ['review_id'], true); + + expect(homeAssistantWSRequest).toBeCalledWith( + hass, + reviewResultSchema, + expect.objectContaining({ + type: 'frigate/reviews/viewed', + instance_id: 'clientID', + ids: ['review_id'], + viewed: true, + }), + ); + }); + + it('unsuccessfully', async () => { + vi.mocked(homeAssistantWSRequest).mockResolvedValue({ + success: false, + message: 'failed', + }); + + const hass = createHASS(); + await expect( + setReviewsReviewed(hass, 'clientID', ['review_id'], true), + ).rejects.toThrowError(/Failed to receive response from Home Assistant/); + expect(homeAssistantWSRequest).toBeCalledWith( + hass, + reviewResultSchema, + expect.objectContaining({ + type: 'frigate/reviews/viewed', + instance_id: 'clientID', + ids: ['review_id'], + viewed: true, + }), + ); + }); + }); }); diff --git a/tests/camera-manager/frigate/util.test.ts b/tests/camera-manager/frigate/util.test.ts index 3ba42f78..8e13474d 100644 --- a/tests/camera-manager/frigate/util.test.ts +++ b/tests/camera-manager/frigate/util.test.ts @@ -8,12 +8,17 @@ import { getRecordingID, getRecordingMediaContentID, getRecordingTitle, + getReviewMediaContentID, + getReviewSeverity, + getReviewThumbnailURL, + getReviewTitle, } from '../../../src/camera-manager/frigate/util'; import { CameraConfig } from '../../../src/config/schema/cameras'; import { createCameraConfig, createFrigateEvent, createFrigateRecording, + createFrigateReview, } from '../../test-utils'; describe('getEventTitle', () => { @@ -152,3 +157,108 @@ describe('getRecordingID', () => { ).toBe('//1682776800000/1682780399000'); }); }); + +describe('getReviewTitle', () => { + const start = new Date('2023-05-06T10:43:00'); + const end = new Date('2023-05-06T10:44:12'); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should get finished review title without objects', () => { + expect( + getReviewTitle( + createFrigateReview({ + start_time: start.getTime() / 1000, + end_time: end.getTime() / 1000, + data: { + objects: [], + zones: [], + }, + }), + ), + ).toBe('2023-05-06 10:43 [72s]'); + }); + + it('should get finished review title with objects', () => { + expect( + getReviewTitle( + createFrigateReview({ + start_time: start.getTime() / 1000, + end_time: end.getTime() / 1000, + data: { + objects: ['person', 'dog'], + }, + }), + ), + ).toBe('Person, Dog'); + }); + + it('should get in-progress review title', () => { + vi.useFakeTimers(); + vi.setSystemTime(add(start, { seconds: 60 })); + + expect( + getReviewTitle( + createFrigateReview({ + start_time: start.getTime() / 1000, + end_time: null, + data: { + objects: [], + }, + }), + ), + ).toBe('2023-05-06 10:43 [60s]'); + }); +}); + +describe('getReviewMediaContentID', () => { + it('should get review content ID', () => { + expect( + getReviewMediaContentID( + 'clientid', + 'kitchen', + createFrigateReview({ + start_time: new Date('2023-04-29T14:00:00').getTime() / 1000, + }), + ), + ).toBe('media-source://frigate/clientid/recordings/kitchen/2023-04-29/14'); + }); +}); + +describe('getReviewThumbnailURL', () => { + it('should get thumbnail URL', () => { + expect( + getReviewThumbnailURL( + 'clientid', + createFrigateReview({ + thumb_path: '/media/frigate/thumb.jpg', + }), + ), + ).toBe('/api/frigate/clientid/thumb.jpg'); + }); + + it('should return null when no thumb path', () => { + expect( + getReviewThumbnailURL( + 'clientid', + createFrigateReview({ + thumb_path: null, + }), + ), + ).toBeNull(); + }); +}); + +describe('getReviewSeverity', () => { + it('should get alert severity', () => { + expect(getReviewSeverity('alert')).toBe('high'); + }); + it('should get detection severity', () => { + expect(getReviewSeverity('detection')).toBe('medium'); + }); + it('should get significant_motion severity', () => { + expect(getReviewSeverity('significant_motion')).toBe('low'); + }); +}); diff --git a/tests/camera-manager/frigate/event-watcher.test.ts b/tests/camera-manager/frigate/watcher.test.ts similarity index 60% rename from tests/camera-manager/frigate/event-watcher.test.ts rename to tests/camera-manager/frigate/watcher.test.ts index a7e2eb1b..06e54017 100644 --- a/tests/camera-manager/frigate/event-watcher.test.ts +++ b/tests/camera-manager/frigate/watcher.test.ts @@ -1,6 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { FrigateEventWatcher } from '../../../src/camera-manager/frigate/event-watcher.js'; -import { FrigateEventChange } from '../../../src/camera-manager/frigate/types.js'; +import { + FrigateEventChange, + FrigateReviewChange, +} from '../../../src/camera-manager/frigate/types.js'; +import { + FrigateEventWatcher, + FrigateReviewWatcher, +} from '../../../src/camera-manager/frigate/watcher.js'; import { HomeAssistant } from '../../../src/ha/types.js'; import { createHASS } from '../../test-utils.js'; @@ -25,7 +31,41 @@ const createEventChange = (): FrigateEventChange => { }, }; }; - +const createReviewChange = (): FrigateReviewChange => { + return { + type: 'new', + before: { + id: '123', + camera: 'front_door', + severity: 'alert', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: { + metadata: { + title: 'Title before', + scene: 'Scene before', + }, + }, + }, + after: { + id: '123', + camera: 'front_door', + severity: 'alert', + start_time: 123, + end_time: null, + thumb_path: null, + has_been_reviewed: false, + data: { + metadata: { + title: 'Title after', + scene: 'Scene after', + }, + }, + }, + }; +}; const callHASubscribeMessageCallback = ( hass: HomeAssistant, data: unknown, @@ -99,7 +139,7 @@ describe('FrigateEventWatcher', () => { expect(callback).not.toBeCalled(); expect(spy).toBeCalledWith( - 'Received non-JSON payload as Frigate event', + 'Received non-JSON payload from subscription: frigate/events/subscribe', 'NOT_JSON', ); }); @@ -122,7 +162,7 @@ describe('FrigateEventWatcher', () => { expect(callback).not.toBeCalled(); expect(spy).toBeCalledWith( - 'Received malformed Frigate event from Home Assistant', + 'Received malformed message from subscription: frigate/events/subscribe', data, ); }); @@ -199,3 +239,95 @@ describe('FrigateEventWatcher', () => { }); }); }); + +describe('FrigateReviewWatcher', () => { + it('should subscribe to a given topic once', async () => { + const stateWatcher = new FrigateReviewWatcher(); + const hass = createHASS(); + + await stateWatcher.subscribe(hass, { + instanceID: 'frigate', + callback: vi.fn(), + }); + + await stateWatcher.subscribe(hass, { + instanceID: 'frigate', + callback: vi.fn(), + }); + + expect(hass.connection.subscribeMessage).toBeCalledWith( + expect.any(Function), + expect.objectContaining({ + type: 'frigate/reviews/subscribe', + }), + ); + expect(hass.connection.subscribeMessage).toBeCalledTimes(1); + }); + + describe('should call handler', () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it('with a review change', async () => { + const stateWatcher = new FrigateReviewWatcher(); + const hass = createHASS(); + + const callback = vi.fn(); + const request = { + instanceID: 'frigate', + callback: callback, + }; + + await stateWatcher.subscribe(hass, request); + + const reviewChange = createReviewChange(); + + callHASubscribeMessageCallback(hass, JSON.stringify(reviewChange)); + + expect(callback).toBeCalledWith(reviewChange); + }); + + it('with a genai review change', async () => { + const stateWatcher = new FrigateReviewWatcher(); + const hass = createHASS(); + + const callback = vi.fn(); + const request = { + instanceID: 'frigate', + callback: callback, + }; + + await stateWatcher.subscribe(hass, request); + + const reviewChange = createReviewChange(); + reviewChange.type = 'genai'; + + callHASubscribeMessageCallback(hass, JSON.stringify(reviewChange)); + + expect(callback).toBeCalledWith(reviewChange); + }); + + it('with invalid JSON', async () => { + const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true); + + const stateWatcher = new FrigateReviewWatcher(); + const hass = createHASS(); + + const callback = vi.fn(); + const request = { + instanceID: 'frigate', + callback: callback, + }; + + await stateWatcher.subscribe(hass, request); + callHASubscribeMessageCallback(hass, 'NOT_JSON'); + + expect(callback).not.toBeCalled(); + expect(spy).toBeCalledWith( + 'Received non-JSON payload from subscription: frigate/reviews/subscribe', + 'NOT_JSON', + ); + }); + }); +}); diff --git a/tests/camera-manager/generic/engine-generic.test.ts b/tests/camera-manager/generic/engine-generic.test.ts index bf7e0fde..d9b55da2 100644 --- a/tests/camera-manager/generic/engine-generic.test.ts +++ b/tests/camera-manager/generic/engine-generic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { GenericCameraManagerEngine } from '../../../src/camera-manager/generic/engine-generic'; import { Engine, QueryResultsType, QueryType } from '../../../src/camera-manager/types'; +import { QuerySource } from '../../../src/query-source'; import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher'; import { CameraConfig } from '../../../src/config/schema/cameras'; import { RawAdvancedCameraCardConfig } from '../../../src/config/types'; @@ -43,6 +44,14 @@ describe('GenericCameraManagerEngine', () => { expect(camera.getCapabilities()?.has('trigger')).toBeTruthy(); }); + it('should get default query parameters', async () => { + const config = createGenericCameraConfig(); + const camera = await createEngine().createCamera(createHASS(), config); + expect(createEngine().getDefaultQueryParameters(camera, QueryType.Event)).toEqual( + {}, + ); + }); + it('should generate default event query', () => { const engine = createEngine(); expect( @@ -82,7 +91,11 @@ describe('GenericCameraManagerEngine', () => { await engine.getEvents( createHASS(), createStore([{ cameraID: 'camera-1', engine: engine }]), - { type: QueryType.Event, cameraIDs: new Set(['camera-1']) }, + { + source: QuerySource.Camera, + type: QueryType.Event, + cameraIDs: new Set(['camera-1']), + }, ), ).toBeNull(); }); @@ -93,7 +106,11 @@ describe('GenericCameraManagerEngine', () => { await engine.getRecordings( createHASS(), createStore([{ cameraID: 'camera-1', engine: engine }]), - { type: QueryType.Recording, cameraIDs: new Set(['camera-1']) }, + { + source: QuerySource.Camera, + type: QueryType.Recording, + cameraIDs: new Set(['camera-1']), + }, ), ).toBeNull(); }); @@ -114,6 +131,32 @@ describe('GenericCameraManagerEngine', () => { ).toBeNull(); }); + it('should generate default review query', () => { + const engine = createEngine(); + expect( + engine.generateDefaultReviewQuery( + createStore([{ cameraID: 'camera-1', engine: engine }]), + new Set(['camera-1']), + {}, + ), + ).toBeNull(); + }); + + it('should get reviews', async () => { + const engine = createEngine(); + expect( + await engine.getReviews( + createHASS(), + createStore([{ cameraID: 'camera-1', engine: engine }]), + { + source: QuerySource.Camera, + type: QueryType.Review, + cameraIDs: new Set(['camera-1']), + }, + ), + ).toBeNull(); + }); + it('should generate media from events', async () => { const engine = createEngine(); expect( @@ -121,6 +164,7 @@ describe('GenericCameraManagerEngine', () => { createHASS(), createStore([{ cameraID: 'camera-1', engine: engine }]), { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['camera-1']), }, @@ -139,6 +183,7 @@ describe('GenericCameraManagerEngine', () => { createHASS(), createStore([{ cameraID: 'camera-1', engine: engine }]), { + source: QuerySource.Camera, type: QueryType.Recording, cameraIDs: new Set(['camera-1']), start: new Date(), @@ -152,6 +197,25 @@ describe('GenericCameraManagerEngine', () => { ).toBeNull(); }); + it('should generate media from reviews', async () => { + const engine = createEngine(); + expect( + engine.generateMediaFromReviews( + createHASS(), + createStore([{ cameraID: 'camera-1', engine: engine }]), + { + source: QuerySource.Camera, + type: QueryType.Review, + cameraIDs: new Set(['camera-1']), + }, + { + type: QueryResultsType.Review, + engine: Engine.Generic, + }, + ), + ).toBeNull(); + }); + it('should get media download path', async () => { expect( await createEngine().getMediaDownloadPath( @@ -173,6 +237,17 @@ describe('GenericCameraManagerEngine', () => { ).toBeUndefined(); }); + it('should review media', async () => { + expect( + await createEngine().reviewMedia( + createHASS(), + createGenericCameraConfig(), + new TestViewMedia(), + true, + ), + ).toBeUndefined(); + }); + it('should get query result max age', () => { expect( createEngine().getQueryResultMaxAge({ diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 7871c604..af74fec9 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -11,8 +11,6 @@ import { QueryResultClassifier, } from '../../src/camera-manager/manager.js'; import { - CameraEndpoints, - CameraEndpointsContext, CameraEvent, CameraManagerCameraMetadata, Engine, @@ -27,23 +25,24 @@ import { CardController } from '../../src/card-controller/controller.js'; import { sortItems } from '../../src/card-controller/view/sort.js'; import { CameraConfig } from '../../src/config/schema/cameras.js'; import { HomeAssistant } from '../../src/ha/types.js'; +import { QuerySource } from '../../src/query-source.js'; import { Endpoint, PTZMovementType } from '../../src/types.js'; import { ViewFolder, ViewItem, ViewMedia } from '../../src/view/item.js'; import { ViewItemCapabilities } from '../../src/view/types.js'; import { TestViewMedia, - createInitializedCamera, createCameraConfig, createCapabilities, createCardAPI, createConfig, createFolder, createHASS, + createInitializedCamera, generateViewMediaArray, } from '../test-utils.js'; -describe('QueryClassifier', async () => { - it('should classify event query', async () => { +describe('QueryClassifier', () => { + it('should classify event query', () => { expect(CameraQueryClassifier.isEventQuery({ type: QueryType.Event })).toBeTruthy(); expect( CameraQueryClassifier.isEventQuery({ type: QueryType.Recording }), @@ -55,7 +54,7 @@ describe('QueryClassifier', async () => { CameraQueryClassifier.isEventQuery({ type: QueryType.MediaMetadata }), ).toBeFalsy(); }); - it('should classify recording query', async () => { + it('should classify recording query', () => { expect( CameraQueryClassifier.isRecordingQuery({ type: QueryType.Event }), ).toBeFalsy(); @@ -69,7 +68,7 @@ describe('QueryClassifier', async () => { CameraQueryClassifier.isRecordingQuery({ type: QueryType.MediaMetadata }), ).toBeFalsy(); }); - it('should classify recording segments query', async () => { + it('should classify recording segments query', () => { expect( CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.Event }), ).toBeFalsy(); @@ -85,7 +84,7 @@ describe('QueryClassifier', async () => { CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.MediaMetadata }), ).toBeFalsy(); }); - it('should classify media metadata query', async () => { + it('should classify media metadata query', () => { expect( CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.Event }), ).toBeFalsy(); @@ -99,6 +98,19 @@ describe('QueryClassifier', async () => { CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.MediaMetadata }), ).toBeTruthy(); }); + it('should classify review query', () => { + expect(CameraQueryClassifier.isReviewQuery({ type: QueryType.Event })).toBeFalsy(); + expect( + CameraQueryClassifier.isReviewQuery({ type: QueryType.Recording }), + ).toBeFalsy(); + expect( + CameraQueryClassifier.isReviewQuery({ type: QueryType.RecordingSegments }), + ).toBeFalsy(); + expect( + CameraQueryClassifier.isReviewQuery({ type: QueryType.MediaMetadata }), + ).toBeFalsy(); + expect(CameraQueryClassifier.isReviewQuery({ type: QueryType.Review })).toBeTruthy(); + }); }); describe('QueryResultClassifier', async () => { @@ -195,9 +207,32 @@ describe('QueryResultClassifier', async () => { ), ).toBeTruthy(); }); + it('should classify review query result', async () => { + expect( + QueryResultClassifier.isReviewQueryResult(createResults(QueryResultsType.Event)), + ).toBeFalsy(); + expect( + QueryResultClassifier.isReviewQueryResult( + createResults(QueryResultsType.Recording), + ), + ).toBeFalsy(); + expect( + QueryResultClassifier.isReviewQueryResult( + createResults(QueryResultsType.RecordingSegments), + ), + ).toBeFalsy(); + expect( + QueryResultClassifier.isReviewQueryResult( + createResults(QueryResultsType.MediaMetadata), + ), + ).toBeFalsy(); + expect( + QueryResultClassifier.isReviewQueryResult(createResults(QueryResultsType.Review)), + ).toBeTruthy(); + }); }); -describe('CameraManager', async () => { +describe('CameraManager', () => { const baseCameraConfig = { id: 'id', camera_entity: 'camera.foo', @@ -205,6 +240,7 @@ describe('CameraManager', async () => { }; const baseEventQuery: EventQuery = { + source: QuerySource.Camera, type: QueryType.Event as const, cameraIDs: new Set(['id']), }; @@ -215,6 +251,7 @@ describe('CameraManager', async () => { }; const baseRecordingQuery = { + source: QuerySource.Camera as const, type: QueryType.Recording as const, cameraIDs: new Set(['id']), }; @@ -266,7 +303,7 @@ describe('CameraManager', async () => { return new CameraManager(api, { factory: mockFactory }); }; - it('should construct', async () => { + it('should construct', () => { const manager = new CameraManager(createCardAPI()); expect(manager.getStore()).toBeTruthy(); }); @@ -440,6 +477,11 @@ describe('CameraManager', async () => { 'generateDefaultRecordingSegmentsQuery', 'generateDefaultRecordingSegmentsQueries', ], + [ + QueryType.Review as const, + 'generateDefaultReviewQuery', + 'generateDefaultReviewQueries', + ], ])( 'basic %s', async ( @@ -482,6 +524,33 @@ describe('CameraManager', async () => { }); }); + describe('getDefaultQueryParameters', () => { + it('should return empty object for non-existent camera', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + + const manager = createCameraManager(api, mock()); + + expect( + manager.getDefaultQueryParameters('not_a_camera', QueryType.Event), + ).toEqual({}); + }); + + it('should return parameters from engine for existing camera', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + + const engine = mock(); + const manager = createCameraManager(api, engine); + expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + + engine.getDefaultQueryParameters.mockReturnValue({ what: new Set(['person']) }); + expect(manager.getDefaultQueryParameters('id', QueryType.Event)).toEqual({ + what: new Set(['person']), + }); + }); + }); + it('should merge defaults correctly', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); @@ -590,6 +659,34 @@ describe('CameraManager', async () => { }); }); + describe('should review media', () => { + it('without camera', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + const manager = createCameraManager(api); + const media = new TestViewMedia(); + + await manager.reviewMedia(media, true); + }); + + it('successfully', async () => { + const api = createCardAPI(); + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const engine = mock(); + vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic); + + const manager = createCameraManager(api, engine); + expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + const media = new TestViewMedia({ cameraID: 'id' }); + + await manager.reviewMedia(media, true); + + expect(engine.reviewMedia).toBeCalledWith(hass, expect.anything(), media, true); + }); + }); + describe('should get recordings', () => { it('successfully', async () => { const api = createCardAPI(); @@ -740,7 +837,7 @@ describe('CameraManager', async () => { new TestViewMedia({ startTime: add(dateBase, { days: 2 }), }), - new ViewFolder(createFolder()), + new ViewFolder(createFolder(), []), ]; it('without hass', async () => { diff --git a/tests/camera-manager/reolink/engine-reolink.test.ts b/tests/camera-manager/reolink/engine-reolink.test.ts index 9b2af4b5..d80e5a7b 100644 --- a/tests/camera-manager/reolink/engine-reolink.test.ts +++ b/tests/camera-manager/reolink/engine-reolink.test.ts @@ -23,6 +23,7 @@ import { QueryReturnType, QueryType, } from '../../../src/camera-manager/types'; +import { QuerySource } from '../../../src/query-source'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { BrowseMedia, browseMediaSchema } from '../../../src/ha/browse-media/types'; import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker'; @@ -340,6 +341,7 @@ describe('ReolinkCameraManagerEngine', () => { expect( await engine.getEvents(createHASS(), createStore(), { ...query, + source: QuerySource.Camera, cameraIDs: new Set(['office']), type: QueryType.Event, }), @@ -360,6 +362,7 @@ describe('ReolinkCameraManagerEngine', () => { createHASS(), store, { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['office']), start: new Date('2024-11-04T21:00:00'), @@ -377,6 +380,7 @@ describe('ReolinkCameraManagerEngine', () => { cameraIDs: new Set(['office']), end: new Date('2024-11-04T22:00:00'), start: new Date('2024-11-04T21:00:00'), + source: 'camera', type: 'event-query', }, { @@ -473,6 +477,7 @@ describe('ReolinkCameraManagerEngine', () => { createHASS(), store, { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['office']), start: new Date('2024-11-04T21:00:00'), @@ -510,6 +515,7 @@ describe('ReolinkCameraManagerEngine', () => { const hass = createHASS(); await engine.getEvents(hass, store, { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['office']), start: new Date('2024-11-04T21:00:00'), @@ -561,6 +567,7 @@ describe('ReolinkCameraManagerEngine', () => { }); const events = await engine.getEvents(createHASS(), store, { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['office']), start: new Date('2024-11-04T21:00:00'), @@ -574,6 +581,7 @@ describe('ReolinkCameraManagerEngine', () => { cameraIDs: new Set(['office']), end: new Date('2024-11-04T22:00:00'), start: new Date('2024-11-04T21:00:00'), + source: 'camera', type: 'event-query', }, { @@ -603,6 +611,7 @@ describe('ReolinkCameraManagerEngine', () => { const store = await createStoreWithReolinkCamera(engine); const events = await engine.getEvents(createHASS(), store, { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['office']), start: new Date('2024-11-04T21:00:00'), @@ -616,6 +625,7 @@ describe('ReolinkCameraManagerEngine', () => { cameraIDs: new Set(['office']), end: new Date('2024-11-04T22:00:00'), start: new Date('2024-11-04T21:00:00'), + source: 'camera', type: 'event-query', }, { @@ -638,6 +648,7 @@ describe('ReolinkCameraManagerEngine', () => { const hass = createHASS(); const events = await engine.getEvents(hass, store, { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['office']), }); @@ -647,6 +658,7 @@ describe('ReolinkCameraManagerEngine', () => { [ { cameraIDs: new Set(['office']), + source: 'camera', type: 'event-query', }, { @@ -678,6 +690,7 @@ describe('ReolinkCameraManagerEngine', () => { }); const events = await engine.getEvents(createHASS(), store, { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['office']), start: new Date('2024-11-04T21:00:00'), @@ -691,6 +704,7 @@ describe('ReolinkCameraManagerEngine', () => { cameraIDs: new Set(['office']), end: new Date('2024-11-04T22:00:00'), start: new Date('2024-11-04T21:00:00'), + source: 'camera', type: 'event-query', }, { @@ -731,6 +745,7 @@ describe('ReolinkCameraManagerEngine', () => { }); const events = await engine.getEvents(createHASS(), store, { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['office']), start: new Date('2024-11-04T21:00:00'), @@ -744,6 +759,7 @@ describe('ReolinkCameraManagerEngine', () => { cameraIDs: new Set(['office']), end: new Date('2024-11-04T22:00:00'), start: new Date('2024-11-04T21:00:00'), + source: 'camera', type: 'event-query', }, { @@ -792,6 +808,7 @@ describe('ReolinkCameraManagerEngine', () => { }); const events = await engine.getEvents(createHASS(), store, { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['office']), start: new Date('2024-11-04T21:00:00'), @@ -805,6 +822,7 @@ describe('ReolinkCameraManagerEngine', () => { cameraIDs: new Set(['office']), end: new Date('2024-11-04T22:00:00'), start: new Date('2024-11-04T21:00:00'), + source: 'camera', type: 'event-query', }, { @@ -851,6 +869,7 @@ describe('ReolinkCameraManagerEngine', () => { }); const events = await engine.getEvents(createHASS(), store, { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['office']), start: new Date('2024-11-04T21:00:00'), @@ -864,6 +883,7 @@ describe('ReolinkCameraManagerEngine', () => { cameraIDs: new Set(['office']), end: new Date('2024-11-04T22:00:00'), start: new Date('2024-11-04T21:00:00'), + source: 'camera', type: 'event-query', }, { @@ -910,6 +930,7 @@ describe('ReolinkCameraManagerEngine', () => { }); const events = await engine.getEvents(createHASS(), store, { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['office']), start: new Date('2024-11-04T21:00:00'), @@ -923,6 +944,7 @@ describe('ReolinkCameraManagerEngine', () => { cameraIDs: new Set(['office']), end: new Date('2024-11-04T22:00:00'), start: new Date('2024-11-04T21:00:00'), + source: 'camera', type: 'event-query', }, { @@ -958,6 +980,7 @@ describe('ReolinkCameraManagerEngine', () => { describe('should generate media from events', () => { it('should generate media successfully', () => { const query: EventQuery = { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['office']), start: new Date('2024-11-04T21:00:00'), @@ -1004,6 +1027,7 @@ describe('ReolinkCameraManagerEngine', () => { it('should reject non-reolink results', () => { const query: EventQuery = { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set(['office']), start: new Date('2024-11-04T21:00:00'), diff --git a/tests/camera-manager/utils/go2rtc-audio.test.ts b/tests/camera-manager/utils/go2rtc-audio.test.ts new file mode 100644 index 00000000..deb402b3 --- /dev/null +++ b/tests/camera-manager/utils/go2rtc-audio.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { supports2WayAudio } from '../../../src/camera-manager/utils/go2rtc/audio'; +import { homeAssistantSignAndFetch } from '../../../src/ha/fetch'; +import { HomeAssistant } from '../../../src/ha/types'; +import { createProxiedEndpointIfNecessary } from '../../../src/ha/web-proxy'; +import { Endpoint } from '../../../src/types'; + +vi.mock('../../../src/ha/fetch'); +vi.mock('../../../src/ha/web-proxy'); + +describe('supports2WayAudio', () => { + const hass = mock(); + const endpoint: Endpoint = { endpoint: 'http://go2rtc', sign: true }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return false if no endpoint provided', async () => { + expect(await supports2WayAudio(hass, null)).toBe(false); + }); + + it('should return false if fetch fails', async () => { + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint); + vi.mocked(homeAssistantSignAndFetch).mockRejectedValue(new Error('fetch error')); + + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = await supports2WayAudio(hass, endpoint); + + expect(result).toBe(false); + expect(spy).toHaveBeenCalledWith('fetch error'); + spy.mockRestore(); + }); + + it('should return false if stream info has no producers', async () => { + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint); + vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ producers: undefined }); + + expect(await supports2WayAudio(hass, endpoint)).toBe(false); + }); + + it('should return false if no producer supports audio', async () => { + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint); + vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ + producers: [ + { + medias: ['video,sendonly,h264'], + }, + ], + }); + + expect(await supports2WayAudio(hass, endpoint)).toBe(false); + }); + + it('should return true if producer supports audio and sendonly', async () => { + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint); + vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ + producers: [ + { + medias: ['audio,sendonly,opus'], + }, + ], + }); + + expect(await supports2WayAudio(hass, endpoint)).toBe(true); + }); + + it('should return true if producer supports audio and sendrecv', async () => { + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint); + vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ + producers: [ + { + medias: ['audio,sendrecv,pcmu'], + }, + ], + }); + + expect(await supports2WayAudio(hass, endpoint)).toBe(true); + }); + + it('should handle missing medias in producer', async () => { + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint); + vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ + producers: [ + { + medias: undefined, + }, + ], + }); + + expect(await supports2WayAudio(hass, endpoint)).toBe(false); + }); +}); diff --git a/tests/camera-manager/utils/ptz.test.ts b/tests/camera-manager/utils/ptz.test.ts index 3e0659be..2a154340 100644 --- a/tests/camera-manager/utils/ptz.test.ts +++ b/tests/camera-manager/utils/ptz.test.ts @@ -4,7 +4,7 @@ import { getConfiguredPTZMovementType, getPTZCapabilitiesFromCameraConfig, } from '../../../src/camera-manager/utils/ptz'; -import { PTZAction } from '../../../src/config/ptz'; +import { PTZAction } from '../../../src/config/schema/actions/custom/ptz'; import { createCameraConfig } from '../../test-utils'; const action = { diff --git a/tests/card-controller/actions/actions/camera-select.test.ts b/tests/card-controller/actions/actions/camera-select.test.ts index 5bca6514..717608db 100644 --- a/tests/card-controller/actions/actions/camera-select.test.ts +++ b/tests/card-controller/actions/actions/camera-select.test.ts @@ -6,7 +6,6 @@ describe('should handle camera_select action', () => { it('with valid camera and view', async () => { const api = createCardAPI(); vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); - vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); const action = new CameraSelectAction( {}, @@ -37,7 +36,6 @@ describe('should handle camera_select action', () => { camera: 'camera.office', }), ); - vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); const action = new CameraSelectAction( {}, @@ -61,7 +59,6 @@ describe('should handle camera_select action', () => { view: 'timeline', }), ); - vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); const action = new CameraSelectAction( {}, @@ -100,7 +97,6 @@ describe('should handle camera_select action', () => { view: 'live', }), ); - vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); const action = new CameraSelectAction( {}, @@ -127,7 +123,6 @@ describe('should handle camera_select action', () => { it('with triggered camera', async () => { const api = createCardAPI(); vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); - vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); vi.mocked(api.getTriggersManager().getMostRecentlyTriggeredCameraID).mockReturnValue( 'camera.office', ); @@ -157,7 +152,6 @@ describe('should handle camera_select action', () => { it('without camera or triggered camera', async () => { const api = createCardAPI(); vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); - vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); vi.mocked(api.getTriggersManager().getMostRecentlyTriggeredCameraID).mockReturnValue( 'camera.office', ); diff --git a/tests/card-controller/actions/actions/info.test.ts b/tests/card-controller/actions/actions/info.test.ts new file mode 100644 index 00000000..5373c89f --- /dev/null +++ b/tests/card-controller/actions/actions/info.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { InfoAction } from '../../../../src/card-controller/actions/actions/info'; +import { QueryResults } from '../../../../src/view/query-results'; +import { View } from '../../../../src/view/view'; +import { createCardAPI, TestViewMedia } from '../../../test-utils'; + +describe('InfoAction', () => { + it('should handle info action with media', async () => { + const api = createCardAPI(); + const item = new TestViewMedia(); + const view = new View({ + view: 'clip', + camera: 'camera', + queryResults: new QueryResults({ + results: [item], + selectedIndex: 0, + }), + }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const action = new InfoAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'info', + }, + ); + + await action.execute(api); + + expect(api.getOverlayMessageManager().setMessage).toBeCalled(); + }); + + it('should not handle info action without media', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(null); + + const action = new InfoAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'info', + }, + ); + + await action.execute(api); + + expect(api.getOverlayMessageManager().setMessage).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/actions/actions/ptz-digital.test.ts b/tests/card-controller/actions/actions/ptz-digital.test.ts index 05dc946f..c2ed966a 100644 --- a/tests/card-controller/actions/actions/ptz-digital.test.ts +++ b/tests/card-controller/actions/actions/ptz-digital.test.ts @@ -4,7 +4,7 @@ import { PartialZoomSettings, ZoomSettingsObserved, } from '../../../../src/components-lib/zoom/types'; -import { PTZAction } from '../../../../src/config/ptz'; +import { PTZAction } from '../../../../src/config/schema/actions/custom/ptz'; import { createCardAPI, createView } from '../../../test-utils'; describe('should handle ptz digital action', () => { diff --git a/tests/card-controller/actions/actions/ptz-multi.test.ts b/tests/card-controller/actions/actions/ptz-multi.test.ts index 70bcd810..b1b4d02d 100644 --- a/tests/card-controller/actions/actions/ptz-multi.test.ts +++ b/tests/card-controller/actions/actions/ptz-multi.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { Capabilities } from '../../../../src/camera-manager/capabilities'; import { PTZMultiAction } from '../../../../src/card-controller/actions/actions/ptz-multi'; +import { PTZMovementType } from '../../../../src/types'; import { createCameraManager, createCardAPI, @@ -23,7 +24,7 @@ describe('should handle ptz multi action', () => { const store = createStore([ { cameraID: 'camera.office', - capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }), }, ]); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); diff --git a/tests/card-controller/actions/actions/set-review.test.ts b/tests/card-controller/actions/actions/set-review.test.ts new file mode 100644 index 00000000..c3a948cf --- /dev/null +++ b/tests/card-controller/actions/actions/set-review.test.ts @@ -0,0 +1,142 @@ +import { assert, describe, expect, it, vi } from 'vitest'; +import { SetReviewAction } from '../../../../src/card-controller/actions/actions/set-review'; +import { ViewMedia, ViewMediaType } from '../../../../src/view/item'; +import { QueryResults } from '../../../../src/view/query-results'; +import { createCardAPI, createView, TestViewMedia } from '../../../test-utils'; + +describe('SetReviewAction', () => { + it('should mark item as reviewed', async () => { + const api = createCardAPI(); + const item = new TestViewMedia({ + cameraID: 'camera.office', + mediaType: 'review' as ViewMediaType, + reviewed: false, + }); + + const queryResults = new QueryResults({ results: [item], selectedIndex: 0 }); + const view = createView({ queryResults }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const action = new SetReviewAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'set_review', + reviewed: true, + }, + ); + await action.execute(api); + + expect(api.getViewItemManager().reviewMedia).toBeCalledWith(item, true); + + // Original item is NOT mutated; a clone is created and replaced. + expect(item.isReviewed()).toBe(false); + + const setViewParams = vi.mocked(api.getViewManager().setViewByParameters).mock + .calls[0][0]; + const newResults = setViewParams?.params?.queryResults; + expect(newResults).toBeInstanceOf(QueryResults); + + const newItem = newResults?.getSelectedResult(); + expect(newItem).not.toBe(item); + assert(newItem instanceof ViewMedia); + expect(newItem?.isReviewed()).toBe(true); + }); + + it('should toggle review status when reviewed is not specified', async () => { + const api = createCardAPI(); + const item = new TestViewMedia({ + cameraID: 'camera.office', + mediaType: 'review' as ViewMediaType, + reviewed: true, + }); + + const queryResults = new QueryResults({ results: [item], selectedIndex: 0 }); + const view = createView({ queryResults }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const action = new SetReviewAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'set_review', + }, + ); + await action.execute(api); + + // Toggle: isReviewed was true, so reviewed should be set to false. + expect(api.getViewItemManager().reviewMedia).toBeCalledWith(item, false); + + // Original item is NOT mutated; a clone is created and replaced. + expect(item.isReviewed()).toBe(true); + + const setViewParams = vi.mocked(api.getViewManager().setViewByParameters).mock + .calls[0][0]; + const newResults = setViewParams?.params?.queryResults; + expect(newResults).toBeInstanceOf(QueryResults); + + const newItem = newResults?.getSelectedResult(); + expect(newItem).not.toBe(item); + assert(newItem instanceof ViewMedia); + expect(newItem?.isReviewed()).toBe(false); + }); + + it('should not act on non-review media', async () => { + const api = createCardAPI(); + const item = new TestViewMedia({ + cameraID: 'camera.office', + mediaType: ViewMediaType.Clip, + }); + + const queryResults = new QueryResults({ results: [item], selectedIndex: 0 }); + const view = createView({ queryResults }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const action = new SetReviewAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'set_review', + reviewed: true, + }, + ); + await action.execute(api); + + expect(api.getViewItemManager().reviewMedia).not.toBeCalled(); + }); + + it('should not act without a view', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(null); + + const action = new SetReviewAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'set_review', + reviewed: true, + }, + ); + await action.execute(api); + + expect(api.getViewItemManager().reviewMedia).not.toBeCalled(); + }); + + it('should not act without query results', async () => { + const api = createCardAPI(); + const view = createView(); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const action = new SetReviewAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'set_review', + reviewed: true, + }, + ); + await action.execute(api); + + expect(api.getViewItemManager().reviewMedia).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/actions/factory.test.ts b/tests/card-controller/actions/factory.test.ts index 27c3507d..2b00d09e 100644 --- a/tests/card-controller/actions/factory.test.ts +++ b/tests/card-controller/actions/factory.test.ts @@ -6,8 +6,10 @@ import { CustomAction } from '../../../src/card-controller/actions/actions/custo import { DefaultAction } from '../../../src/card-controller/actions/actions/default'; import { DisplayModeSelectAction } from '../../../src/card-controller/actions/actions/display-mode-select'; import { DownloadAction } from '../../../src/card-controller/actions/actions/download'; +import { EffectAction } from '../../../src/card-controller/actions/actions/effect'; import { ExpandAction } from '../../../src/card-controller/actions/actions/expand'; import { FullscreenAction } from '../../../src/card-controller/actions/actions/fullscreen'; +import { InfoAction } from '../../../src/card-controller/actions/actions/info'; import { InternalCallbackAction } from '../../../src/card-controller/actions/actions/internal-callback'; import { LogAction } from '../../../src/card-controller/actions/actions/log'; import { MediaPlayerAction } from '../../../src/card-controller/actions/actions/media-player'; @@ -29,6 +31,7 @@ import { PTZDigitalAction } from '../../../src/card-controller/actions/actions/p import { PTZMultiAction } from '../../../src/card-controller/actions/actions/ptz-multi'; import { ReloadAction } from '../../../src/card-controller/actions/actions/reload'; import { ScreenshotAction } from '../../../src/card-controller/actions/actions/screenshot'; +import { SetReviewAction } from '../../../src/card-controller/actions/actions/set-review'; import { SleepAction } from '../../../src/card-controller/actions/actions/sleep'; import { StatusBarAction } from '../../../src/card-controller/actions/actions/status-bar'; import { SubstreamOffAction } from '../../../src/card-controller/actions/actions/substream-off'; @@ -41,7 +44,6 @@ import { ViewAction } from '../../../src/card-controller/actions/actions/view'; import { ActionFactory } from '../../../src/card-controller/actions/factory'; import { INTERNAL_CALLBACK_ACTION } from '../../../src/config/schema/actions/custom/internal'; import { ActionConfig } from '../../../src/config/schema/actions/types'; -import { EffectAction } from '../../../src/card-controller/actions/actions/effect'; // @vitest-environment jsdom describe('ActionFactory', () => { @@ -101,6 +103,7 @@ describe('ActionFactory', () => { [{ advanced_camera_card_action: 'folder' as const }, ViewAction], [{ advanced_camera_card_action: 'folders' as const }, ViewAction], [{ advanced_camera_card_action: 'fullscreen' as const }, FullscreenAction], + [{ advanced_camera_card_action: 'info' as const }, InfoAction], [{ advanced_camera_card_action: 'image' as const }, ViewAction], [ { advanced_camera_card_action: 'live_substream_off' as const }, @@ -189,6 +192,7 @@ describe('ActionFactory', () => { InternalCallbackAction, ], [{ advanced_camera_card_action: 'reload' as const }, ReloadAction], + [{ advanced_camera_card_action: 'set_review' as const }, SetReviewAction], [{ advanced_camera_card_action: 'effect' as const }, EffectAction], ])( 'advanced_camera_card_action: $advanced_camera_card_action', diff --git a/tests/card-controller/card-element-manager.test.ts b/tests/card-controller/card-element-manager.test.ts index 589274b7..61f4e527 100644 --- a/tests/card-controller/card-element-manager.test.ts +++ b/tests/card-controller/card-element-manager.test.ts @@ -2,12 +2,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CardElementManager } from '../../src/card-controller/card-element-manager'; import { StateWatcher } from '../../src/card-controller/hass/state-watcher'; +import { QueryResults } from '../../src/view/query-results'; import { callStateWatcherCallback, createCardAPI, createConfig, createLitElement, createStateEntity, + createView, + TestViewMedia, } from '../test-utils'; // @vitest-environment jsdom @@ -260,5 +263,75 @@ describe('CardElementManager', () => { expect(element.requestUpdate).toBeCalled(); }); + + it('selected media review status changes', () => { + const api = createCardAPI(); + const selectedMedia = new TestViewMedia({ id: 'media-1' }); + const queryResults = new QueryResults({ + results: [selectedMedia], + selectedIndex: 0, + }); + const view = createView({ queryResults }); + + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const element = createLitElement(); + const manager = new CardElementManager( + api, + element, + () => undefined, + () => undefined, + ); + + manager.elementConnected(); + + // Clear any previous calls from elementConnected. + vi.mocked(element.requestUpdate).mockClear(); + + // Dispatch the media reviewed event with the selected media item. + element.dispatchEvent( + new CustomEvent('advanced-camera-card:media:reviewed', { + detail: selectedMedia, + }), + ); + + expect(element.requestUpdate).toBeCalled(); + }); + + it('non-selected media review status changes does not update', () => { + const api = createCardAPI(); + const selectedMedia = new TestViewMedia({ id: 'media-1' }); + const otherMedia = new TestViewMedia({ id: 'media-2' }); + const queryResults = new QueryResults({ + results: [selectedMedia, otherMedia], + selectedIndex: 0, + }); + const view = createView({ queryResults }); + + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const element = createLitElement(); + const manager = new CardElementManager( + api, + element, + () => undefined, + () => undefined, + ); + + manager.elementConnected(); + + // Clear any previous calls from elementConnected. + vi.mocked(element.requestUpdate).mockClear(); + + // Dispatch the media reviewed event with a DIFFERENT media item. + element.dispatchEvent( + new CustomEvent('advanced-camera-card:media:reviewed', { + detail: otherMedia, + }), + ); + + // Should NOT update because the reviewed item is not the selected item. + expect(element.requestUpdate).not.toBeCalled(); + }); }); }); diff --git a/tests/card-controller/config/load-keyboard-shortcuts.test.ts b/tests/card-controller/config/load-keyboard-shortcuts.test.ts index 996342c7..e0ceb9ea 100644 --- a/tests/card-controller/config/load-keyboard-shortcuts.test.ts +++ b/tests/card-controller/config/load-keyboard-shortcuts.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import { createCardAPI, createConfig } from '../../test-utils'; import { setKeyboardShortcutsFromConfig } from '../../../src/card-controller/config/load-keyboard-shortcuts'; -import { PTZKeyboardShortcutName } from '../../../src/config/keyboard-shortcuts'; -import { PTZAction } from '../../../src/config/ptz'; +import { PTZAction } from '../../../src/config/schema/actions/custom/ptz'; +import { PTZKeyboardShortcutName } from '../../../src/config/schema/view'; +import { createCardAPI, createConfig } from '../../test-utils'; describe('setKeyboardShortcutsFromConfig', () => { it('without shortcuts', () => { diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index b0cf8ac4..5f32fa48 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; import { CameraManager } from '../../src/camera-manager/manager'; import { ActionsManager } from '../../src/card-controller/actions/actions-manager'; import { AutomationsManager } from '../../src/card-controller/automations-manager'; @@ -21,6 +22,7 @@ import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-man import { MediaPlayerManager } from '../../src/card-controller/media-player-manager'; import { MessageManager } from '../../src/card-controller/message-manager'; import { MicrophoneManager } from '../../src/card-controller/microphone-manager'; +import { OverlayMessageManager } from '../../src/card-controller/overlay-message-manager'; import { QueryStringManager } from '../../src/card-controller/query-string-manager'; import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager'; import { StyleManager } from '../../src/card-controller/style-manager'; @@ -33,7 +35,6 @@ import { DeviceRegistryManager } from '../../src/ha/registry/device'; import { EntityRegistryManagerLive } from '../../src/ha/registry/entity'; import { ResolvedMediaCache } from '../../src/ha/resolved-media'; import { EffectsControllerAPI } from '../../src/types'; -import { mock } from 'vitest-mock-extended'; vi.mock('../../src/camera-manager/manager'); vi.mock('../../src/card-controller/actions/actions-manager'); @@ -54,6 +55,7 @@ vi.mock('../../src/card-controller/media-info-manager'); vi.mock('../../src/card-controller/media-player-manager'); vi.mock('../../src/card-controller/message-manager'); vi.mock('../../src/card-controller/microphone-manager'); +vi.mock('../../src/card-controller/overlay-message-manager'); vi.mock('../../src/card-controller/query-string-manager'); vi.mock('../../src/card-controller/status-bar-item-manager'); vi.mock('../../src/card-controller/style-manager'); @@ -231,6 +233,12 @@ describe('CardController', () => { ); }); + it('getOverlayMessageManager', () => { + expect(createController().getOverlayMessageManager()).toBe( + vi.mocked(OverlayMessageManager).mock.instances[0], + ); + }); + it('getMicrophoneManager', () => { expect(createController().getMicrophoneManager()).toBe( vi.mocked(MicrophoneManager).mock.instances[0], diff --git a/tests/card-controller/folders/executor.test.ts b/tests/card-controller/folders/executor.test.ts index 9bfe61e4..14eedc3a 100644 --- a/tests/card-controller/folders/executor.test.ts +++ b/tests/card-controller/folders/executor.test.ts @@ -4,6 +4,7 @@ import { FoldersExecutor } from '../../../src/card-controller/folders/executor'; import { HAFoldersEngine } from '../../../src/card-controller/folders/ha/engine'; import { FolderQuery } from '../../../src/card-controller/folders/types'; import { FolderConfig } from '../../../src/config/schema/folders'; +import { QuerySource } from '../../../src/query-source'; import { Endpoint } from '../../../src/types'; import { ViewFolder } from '../../../src/view/item'; import { createFolder, createHASS, TestViewMedia } from '../../test-utils'; @@ -53,6 +54,34 @@ describe('FoldersExecutor', () => { }); }); + describe('getDefaultQueryParameters', () => { + it('should return null for non-existent folder engine', () => { + const folder: FolderConfig = { + type: 'UNKNOWN', + } as unknown as FolderConfig; + const executor = new FoldersExecutor(); + + expect(executor.getDefaultQueryParameters(folder)).toBeNull(); + }); + + it('should get default query parameters for HA folder engine', () => { + const folder = createFolder(); + const haFolderEngine = mock(); + const expectedQuery: FolderQuery = { + source: QuerySource.Folder, + folder: folder, + path: [{}], + }; + haFolderEngine.getDefaultQueryParameters.mockReturnValue(expectedQuery); + + const executor = new FoldersExecutor({ + ha: haFolderEngine, + }); + + expect(executor.getDefaultQueryParameters(folder)).toEqual(expectedQuery); + }); + }); + describe('getDownloadPath', () => { it('should not get download path for non-existent folder engine', async () => { const folder: FolderConfig = { @@ -105,39 +134,15 @@ describe('FoldersExecutor', () => { }); }); - describe('generateDefaultFolderQuery', () => { - it('should generate default folder query', () => { - const folder: FolderConfig = createFolder(); - const query: FolderQuery = { - folder, - path: [{ ha: { id: 'media-source://' } }], - }; - - const haFolderEngine = mock(); - haFolderEngine.generateDefaultFolderQuery.mockReturnValue(query); - const executor = new FoldersExecutor({ ha: haFolderEngine }); - - expect(executor.generateDefaultFolderQuery(folder)).toEqual(query); - }); - - it('should return null for non-existent folder engine', () => { - const folder: FolderConfig = { - type: 'UNKNOWN', - } as unknown as FolderConfig; - const executor = new FoldersExecutor(); - - expect(executor.generateDefaultFolderQuery(folder)).toBeNull(); - }); - }); - describe('generateChildFolderQuery', () => { it('should generate child folder query', () => { const folder: FolderConfig = createFolder(); const query: FolderQuery = { + source: QuerySource.Folder, folder, path: [{ ha: { id: 'media-source://' } }], }; - const viewFolder = new ViewFolder(folder); + const viewFolder = new ViewFolder(folder, []); const haFolderEngine = mock(); haFolderEngine.generateChildFolderQuery.mockReturnValue(query); @@ -152,10 +157,11 @@ describe('FoldersExecutor', () => { type: 'UNKNOWN', } as unknown as FolderConfig; const query: FolderQuery = { + source: QuerySource.Folder, folder, path: [{ ha: { id: 'media-source://' } }], }; - const viewFolder = new ViewFolder(folder); + const viewFolder = new ViewFolder(folder, []); const executor = new FoldersExecutor(); expect(executor.generateChildFolderQuery(query, viewFolder)).toBeNull(); @@ -176,6 +182,7 @@ describe('FoldersExecutor', () => { it('should expand folder', async () => { const folder = createFolder(); const query: FolderQuery = { + source: QuerySource.Folder, folder, path: [{ ha: { id: 'media-source://' } }], }; @@ -184,7 +191,7 @@ describe('FoldersExecutor', () => { folder, startTime: new Date('2023-04-29T14:27'), }); - const folderItem = new ViewFolder(folder); + const folderItem = new ViewFolder(folder, []); const haFolderEngine = mock(); haFolderEngine.expandFolder.mockResolvedValue([folderItem, mediaItem, folderItem]); @@ -201,4 +208,30 @@ describe('FoldersExecutor', () => { ]); }); }); + + describe('areResultsFresh', () => { + it('should return true for non-existent engine', () => { + const folder: FolderConfig = { + type: 'UNKNOWN', + } as unknown as FolderConfig; + const query: FolderQuery = { source: QuerySource.Folder, folder, path: [{}] }; + const executor = new FoldersExecutor(); + + expect(executor.areResultsFresh(new Date(), query)).toBe(true); + }); + + it('should get results from HA engine', () => { + const folder = createFolder(); + const query: FolderQuery = { source: QuerySource.Folder, folder, path: [{}] }; + const haFolderEngine = mock(); + haFolderEngine.areResultsFresh.mockReturnValue(false); + + const executor = new FoldersExecutor({ + ha: haFolderEngine, + }); + + expect(executor.areResultsFresh(new Date(), query)).toBe(false); + expect(haFolderEngine.areResultsFresh).toHaveBeenCalled(); + }); + }); }); diff --git a/tests/card-controller/folders/ha/engine.test.ts b/tests/card-controller/folders/ha/engine.test.ts index a9dabfc4..912e2a07 100644 --- a/tests/card-controller/folders/ha/engine.test.ts +++ b/tests/card-controller/folders/ha/engine.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { HAFoldersEngine } from '../../../../src/card-controller/folders/ha/engine'; import { FolderQuery } from '../../../../src/card-controller/folders/types'; import { FolderConfig, Matcher } from '../../../../src/config/schema/folders'; @@ -6,6 +6,7 @@ import { BrowseMediaViewFolder } from '../../../../src/ha/browse-media/item'; import { browseMediaSchema } from '../../../../src/ha/browse-media/types'; import { getMediaDownloadPath } from '../../../../src/ha/download'; import { homeAssistantWSRequest } from '../../../../src/ha/ws-request'; +import { QuerySource } from '../../../../src/query-source'; import { Endpoint } from '../../../../src/types'; import { ViewFolder, ViewMedia } from '../../../../src/view/item'; import { @@ -25,7 +26,7 @@ describe('HAFoldersEngine', () => { describe('getItemCapabilities', () => { it('should not be able to download a folder', () => { - const item = new ViewFolder(createFolder()); + const item = new ViewFolder(createFolder(), []); const engine = new HAFoldersEngine(); expect(engine.getItemCapabilities(item)).toEqual({ @@ -47,7 +48,7 @@ describe('HAFoldersEngine', () => { describe('getDownloadPath', () => { it('should return null if item is not a media item', async () => { - const item = new ViewFolder(createFolder()); + const item = new ViewFolder(createFolder(), []); const engine = new HAFoldersEngine(); expect(await engine.getDownloadPath(createHASS(), item)).toBeNull(); }); @@ -74,66 +75,32 @@ describe('HAFoldersEngine', () => { }); }); - describe('should generate default folder query', () => { - it('should generate default folder query', () => { - const folder: FolderConfig = { type: 'ha', id: 'test' }; + describe('getDefaultQueryParameters', () => { + it('should return null for non-ha folder config', () => { + const folder: FolderConfig = { + type: 'UNKNOWN', + } as unknown as FolderConfig; const engine = new HAFoldersEngine(); - const query = engine.generateDefaultFolderQuery(folder); - expect(query).toEqual({ - folder, + expect(engine.getDefaultQueryParameters(folder)).toBeNull(); + }); + + it('should return default query parameters for ha folder config', () => { + const folder = createFolder(); + const engine = new HAFoldersEngine(); + + expect(engine.getDefaultQueryParameters(folder)).toEqual({ + source: QuerySource.Folder, + folder: folder, path: [{ ha: { id: 'media-source://' } }], }); }); - - it('should reject folders of the wrong type', async () => { - const folder = createFolder({ type: 'UNKNOWN' } as unknown as FolderConfig); - const engine = new HAFoldersEngine(); - - expect(engine.generateDefaultFolderQuery(folder)).toBeNull(); - }); - - it('should respect path_url as a priority', async () => { - const folder = createFolder({ - ha: { - url: [{ id: 'media-source://1' }], - path: [{ id: 'media-source://2' }], - }, - }); - const engine = new HAFoldersEngine(); - expect(engine.generateDefaultFolderQuery(folder)).toEqual({ - folder, - path: [{ ha: { id: 'media-source://1' } }, { ha: { id: 'media-source://2' } }], - }); - }); - - it('should respect configured url', async () => { - const folder = createFolder({ ha: { url: [{ id: 'media-source://' }] } }); - const engine = new HAFoldersEngine(); - expect(engine.generateDefaultFolderQuery(folder)).toEqual({ - folder, - path: [{ ha: { id: 'media-source://' } }], - }); - }); - - it('should add default media root as necessary', async () => { - const folder = createFolder({ - ha: { path: [{ matchers: [{ type: 'title', title: 'Frigate' }] }] }, - }); - const engine = new HAFoldersEngine(); - expect(engine.generateDefaultFolderQuery(folder)).toEqual({ - folder, - path: [ - { ha: { id: 'media-source://' } }, - { ha: { matchers: [{ type: 'title', title: 'Frigate' }] } }, - ], - }); - }); }); describe('should expand folder', () => { it('should reject folders of the wrong type', async () => { const query = { + source: QuerySource.Folder, folder: { type: 'UNKNOWN' }, } as unknown as FolderQuery; const engine = new HAFoldersEngine(); @@ -143,6 +110,7 @@ describe('HAFoldersEngine', () => { it('should expand folder with cache by default', async () => { const query: FolderQuery = { + source: QuerySource.Folder, folder: { type: 'ha', id: 'test' }, path: [{ ha: { id: 'media-source://id' } }], }; @@ -180,6 +148,7 @@ describe('HAFoldersEngine', () => { it('should expand folder without cache when requested', async () => { const query: FolderQuery = { + source: QuerySource.Folder, folder: { type: 'ha', id: 'test' }, path: [{ ha: { id: 'media-source://id' } }], }; @@ -231,10 +200,11 @@ describe('HAFoldersEngine', () => { }); const query: FolderQuery = { + source: QuerySource.Folder, folder: { type: 'ha', id: 'test' }, path: [ { - folder: new BrowseMediaViewFolder(createFolder(), browseMedia), + folder: new BrowseMediaViewFolder(createFolder(), [], browseMedia), }, ], }; @@ -259,6 +229,7 @@ describe('HAFoldersEngine', () => { it('should not expand without a folder with an id', async () => { const query: FolderQuery = { + source: QuerySource.Folder, folder: { type: 'ha', id: 'test' }, // There's no component in the query with an id to start from. path: [{ ha: {} }], @@ -267,6 +238,38 @@ describe('HAFoldersEngine', () => { expect(await engine.expandFolder(createHASS(), query)).toBeNull(); }); + it('should early exit when limit is reached', async () => { + const query: FolderQuery = { + source: QuerySource.Folder, + folder: { type: 'ha', id: 'test' }, + path: [{ ha: { id: 'media-source://id' } }], + limit: 1, + }; + + vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce( + createBrowseMedia({ + media_content_id: 'media-source://id', + can_expand: true, + children: [ + createBrowseMedia({ + media_content_id: 'media-source://media-item-1', + title: 'Media Item 1', + }), + createBrowseMedia({ + media_content_id: 'media-source://media-item-2', + title: 'Media Item 2', + }), + ], + }), + ); + + const engine = new HAFoldersEngine(); + const results = await engine.expandFolder(createHASS(), query); + + // Even though there are 2 children, the limit of 1 should trigger earlyExit. + expect(results?.length).toBe(1); + }); + // See additional matcher testing in media-matcher.test.ts . describe('should apply matchers', async () => { it.each([ @@ -293,6 +296,7 @@ describe('HAFoldersEngine', () => { ], ])('%s', async (_name: string, matcher: Matcher, expectedMatches: number) => { const query: FolderQuery = { + source: QuerySource.Folder, folder: { type: 'ha', id: 'test' }, path: [{ ha: { id: 'media-source://' } }, { ha: { matchers: [matcher] } }, {}], }; @@ -335,10 +339,11 @@ describe('HAFoldersEngine', () => { it('should return null if folder type is not ha', () => { const engine = new HAFoldersEngine(); const query: FolderQuery = { + source: QuerySource.Folder, folder: { type: 'other' } as unknown as FolderConfig, path: [{ ha: { id: 'root' } }], }; - const folder = new ViewFolder(createFolder()); + const folder = new ViewFolder(createFolder(), []); expect(engine.generateChildFolderQuery(query, folder)).toBeNull(); }); @@ -346,10 +351,11 @@ describe('HAFoldersEngine', () => { it('should return null if folder has no id', () => { const engine = new HAFoldersEngine(); const query: FolderQuery = { + source: QuerySource.Folder, folder: { type: 'ha', id: 'test' }, path: [{ ha: { id: 'root' } }], }; - const folder = new ViewFolder(createFolder(), { id: '' }); + const folder = new ViewFolder(createFolder(), [], { id: '' }); expect(engine.generateChildFolderQuery(query, folder)).toBeNull(); }); @@ -364,14 +370,16 @@ describe('HAFoldersEngine', () => { }, }; const query: FolderQuery = { + source: QuerySource.Folder, folder: folderConfig, path: [{ ha: { id: 'media-source://' } }], }; - const folder = new ViewFolder(createFolder(), { id: 'child' }); + const folder = new ViewFolder(createFolder(), [], { id: 'child' }); const result = engine.generateChildFolderQuery(query, folder); expect(result).toEqual({ + source: QuerySource.Folder, folder: folderConfig, path: [ { ha: { id: 'media-source://' } }, @@ -387,14 +395,16 @@ describe('HAFoldersEngine', () => { const engine = new HAFoldersEngine(); const folderConfig: FolderConfig = { type: 'ha', id: 'test' }; const query: FolderQuery = { + source: QuerySource.Folder, folder: folderConfig, path: [{ ha: { id: 'media-source://' } }], }; - const folder = new ViewFolder(createFolder(), { id: 'child' }); + const folder = new ViewFolder(createFolder(), [], { id: 'child' }); const result = engine.generateChildFolderQuery(query, folder); expect(result).toEqual({ + source: QuerySource.Folder, folder: folderConfig, path: [ { ha: { id: 'media-source://' } }, @@ -406,4 +416,36 @@ describe('HAFoldersEngine', () => { }); }); }); + + describe('areResultsFresh', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should return true for fresh results', () => { + const now = new Date('2026-01-02T07:54:32Z'); + vi.setSystemTime(now); + + const engine = new HAFoldersEngine(); + const query = { folder: { type: 'ha' } } as FolderQuery; + const resultsTimestamp = new Date('2026-01-02T07:54:30Z'); + + expect(engine.areResultsFresh(resultsTimestamp, query)).toBe(true); + }); + + it('should return false for stale results', () => { + const now = new Date('2026-01-02T07:54:32Z'); + vi.setSystemTime(now); + + const engine = new HAFoldersEngine(); + const query = { folder: { type: 'ha' } } as FolderQuery; + const resultsTimestamp = new Date('2026-01-02T07:53:30Z'); + + expect(engine.areResultsFresh(resultsTimestamp, query)).toBe(false); + }); + }); }); diff --git a/tests/card-controller/folders/manager.test.ts b/tests/card-controller/folders/manager.test.ts index 050a09c7..5db2819c 100644 --- a/tests/card-controller/folders/manager.test.ts +++ b/tests/card-controller/folders/manager.test.ts @@ -4,6 +4,7 @@ import { FoldersExecutor } from '../../../src/card-controller/folders/executor'; import { FoldersManager } from '../../../src/card-controller/folders/manager'; import { FolderQuery } from '../../../src/card-controller/folders/types'; import { FolderConfig, FolderConfigWithoutID } from '../../../src/config/schema/folders'; +import { QuerySource } from '../../../src/query-source'; import { ResolvedMediaCache } from '../../../src/ha/resolved-media'; import { Endpoint } from '../../../src/types'; import { ViewFolder } from '../../../src/view/item'; @@ -99,6 +100,39 @@ describe('FoldersManager', () => { expect(manager.getFolderCount()).toBe(0); }); + describe('getDefaultQueryParameters', () => { + it('should return null with no folder provided and no internal folders', () => { + const manager = new FoldersManager(createCardAPI()); + expect(manager.getDefaultQueryParameters()).toBeNull(); + }); + + it('should use provided folder', () => { + const folder = createFolder(); + const executor = mock(); + const manager = new FoldersManager(createCardAPI(), executor); + const query: FolderQuery = { source: QuerySource.Folder, folder, path: [{}] }; + + executor.getDefaultQueryParameters.mockReturnValue(query); + + expect(manager.getDefaultQueryParameters(folder)).toEqual(query); + expect(executor.getDefaultQueryParameters).toBeCalledWith(folder); + }); + + it('should fallback to default folder if none provided', () => { + const folder = createFolder(); + const executor = mock(); + const manager = new FoldersManager(createCardAPI(), executor); + + const query: FolderQuery = { source: QuerySource.Folder, folder, path: [{}] }; + + manager.addFolders([folder]); + executor.getDefaultQueryParameters.mockReturnValue(query); + + expect(manager.getDefaultQueryParameters()).toEqual(query); + expect(executor.getDefaultQueryParameters).toBeCalledWith(manager.getFolder()); + }); + }); + describe('should get folders', () => { it('should get default folder', () => { const manager = new FoldersManager(createCardAPI()); @@ -132,63 +166,15 @@ describe('FoldersManager', () => { }); }); - describe('generateDefaultFolderQuery', () => { - it('should generate default folder query with implicit folder', () => { - const folder: FolderConfig = createFolder(); - const query: FolderQuery = { - folder, - path: [{ ha: { id: 'media-source://' } }], - }; - - const executor = mock(); - vi.mocked(executor.generateDefaultFolderQuery).mockReturnValue(query); - - const manager = new FoldersManager(createCardAPI(), executor); - manager.addFolders([folder]); - - expect(manager.generateDefaultFolderQuery()).toEqual(query); - }); - - it('should generate default folder query without any folder', () => { - const folder: FolderConfig = createFolder(); - const query: FolderQuery = { - folder, - path: [{ ha: { id: 'media-source://' } }], - }; - - const executor = mock(); - vi.mocked(executor.generateDefaultFolderQuery).mockReturnValue(query); - - const manager = new FoldersManager(createCardAPI(), executor); - // Folder is not added to the manager. - - expect(manager.generateDefaultFolderQuery()).toBeNull(); - }); - - it('should generate default folder query with explicit folder', () => { - const folder: FolderConfig = createFolder(); - const query: FolderQuery = { - folder, - path: [{ ha: { id: 'media-source://' } }], - }; - - const executor = mock(); - vi.mocked(executor.generateDefaultFolderQuery).mockReturnValue(query); - - const manager = new FoldersManager(createCardAPI(), executor); - - expect(manager.generateDefaultFolderQuery(folder)).toEqual(query); - }); - }); - describe('generateChildFolderQuery', () => { it('should generate child folder query', () => { const folder: FolderConfig = createFolder(); const query: FolderQuery = { + source: QuerySource.Folder, folder, path: [{ ha: { id: 'media-source://' } }], }; - const viewFolder = new ViewFolder(folder); + const viewFolder = new ViewFolder(folder, []); const executor = mock(); vi.mocked(executor.generateChildFolderQuery).mockReturnValue(query); @@ -216,6 +202,7 @@ describe('FoldersManager', () => { const conditionState = {}; const engineOptions = {}; const query: FolderQuery = { + source: QuerySource.Folder, folder, path: [{ ha: { id: 'media-source://' } }], }; @@ -240,6 +227,7 @@ describe('FoldersManager', () => { expect( await manager.expandFolder({ + source: QuerySource.Folder, folder, path: [{ ha: { id: 'media-source://' } }], }), @@ -247,6 +235,26 @@ describe('FoldersManager', () => { expect(executor.expandFolder).not.toBeCalled(); }); + + it('should return null for query with unsupported filters', async () => { + const hass = createHASS(); + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const executor = mock(); + const manager = new FoldersManager(api, executor); + + const folder = createFolder({ id: 'folder-1' }); + const query: FolderQuery = { + source: QuerySource.Folder, + folder, + path: [{ ha: { id: 'media-source://' } }], + favorite: true, + }; + + expect(await manager.expandFolder(query)).toBeNull(); + expect(executor.expandFolder).not.toBeCalled(); + }); }); describe('should get item capabilities', () => { @@ -302,4 +310,23 @@ describe('FoldersManager', () => { expect(executor.favorite).toBeCalledWith(hass, item, true); }); }); + + describe('areResultsFresh', () => { + it('should delegate freshness check to executor', () => { + const api = createCardAPI(); + const executor = mock(); + const manager = new FoldersManager(api, executor); + const query: FolderQuery = { + source: QuerySource.Folder, + folder: createFolder(), + path: [{}], + }; + const timestamp = new Date(); + + executor.areResultsFresh.mockReturnValue(true); + + expect(manager.areResultsFresh(timestamp, query)).toBe(true); + expect(executor.areResultsFresh).toHaveBeenCalledWith(timestamp, query); + }); + }); }); diff --git a/tests/card-controller/overlay-message-manager.test.ts b/tests/card-controller/overlay-message-manager.test.ts new file mode 100644 index 00000000..e5eb28c5 --- /dev/null +++ b/tests/card-controller/overlay-message-manager.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CardElementManager } from '../../src/card-controller/card-element-manager'; +import { OverlayMessageManager } from '../../src/card-controller/overlay-message-manager'; +import { CardOverlayMessageAPI } from '../../src/card-controller/types'; + +describe('OverlayMessageManager', () => { + const cardElementManager = mock(); + const api = mock(); + + beforeEach(() => { + vi.clearAllMocks(); + api.getCardElementManager.mockReturnValue(cardElementManager); + }); + + it('should be constructed', () => { + const manager = new OverlayMessageManager(api); + expect(manager).toBeDefined(); + expect(manager.getMessage()).toBeNull(); + expect(manager.hasMessage()).toBeFalsy(); + }); + + it('should set and get message', () => { + const manager = new OverlayMessageManager(api); + const message = { message: 'foo' }; + manager.setMessage(message); + + expect(manager.getMessage()).toBe(message); + expect(manager.hasMessage()).toBeTruthy(); + expect(cardElementManager.update).toHaveBeenCalled(); + }); + + it('should reset message', () => { + const manager = new OverlayMessageManager(api); + manager.setMessage({ message: 'foo' }); + vi.clearAllMocks(); + + manager.reset(); + + expect(manager.getMessage()).toBeNull(); + expect(manager.hasMessage()).toBeFalsy(); + expect(cardElementManager.update).toHaveBeenCalled(); + }); + + it('should not update if reset is called with no message', () => { + const manager = new OverlayMessageManager(api); + manager.reset(); + + expect(cardElementManager.update).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/card-controller/status-bar-item-manager.test.ts b/tests/card-controller/status-bar-item-manager.test.ts index 027cf770..8f757e28 100644 --- a/tests/card-controller/status-bar-item-manager.test.ts +++ b/tests/card-controller/status-bar-item-manager.test.ts @@ -240,5 +240,28 @@ describe('StatusBarItemManager', () => { icon: 'ENGINE_ICON', }); }); + + it('should have severity', () => { + const manager = new StatusBarItemManager(createCardAPI()); + const selectedResult = new TestViewMedia({ + cameraID: 'camera-1', + }); + vi.spyOn(selectedResult, 'getSeverity').mockReturnValue('high'); + + const queryResults = new QueryResults({ + results: [selectedResult], + selectedIndex: 0, + }); + + expect( + manager.calculateItems({ + view: createView({ queryResults: queryResults }), + }), + ).toContainEqual({ + type: 'custom:advanced-camera-card-status-bar-icon' as const, + icon: 'mdi:circle-medium', + severity: 'high', + }); + }); }); }); diff --git a/tests/card-controller/templates/index.test.ts b/tests/card-controller/templates/index.test.ts new file mode 100644 index 00000000..809fa201 --- /dev/null +++ b/tests/card-controller/templates/index.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest'; +import { TemplateRenderer } from '../../../src/card-controller/templates/index'; +import { createHASS } from '../../test-utils'; + +describe('TemplateRenderer', () => { + describe('renderRecursively', () => { + it('should render string templates with camera context', () => { + const renderer = new TemplateRenderer(); + const hass = createHASS(); + + const result = renderer.renderRecursively(hass, 'Camera: {{ acc.camera }}', { + conditionState: { camera: 'camera.office' }, + }); + expect(result).toBe('Camera: camera.office'); + }); + + it('should render string templates with view context', () => { + const renderer = new TemplateRenderer(); + const hass = createHASS(); + + const result = renderer.renderRecursively(hass, 'View: {{ acc.view }}', { + conditionState: { view: 'live' }, + }); + expect(result).toBe('View: live'); + }); + + it('should render string templates with full advanced_camera_card context', () => { + const renderer = new TemplateRenderer(); + const hass = createHASS(); + + const result = renderer.renderRecursively( + hass, + '{{ advanced_camera_card.camera }} - {{ advanced_camera_card.view }}', + { + conditionState: { camera: 'camera.front', view: 'clips' }, + }, + ); + expect(result).toBe('camera.front - clips'); + }); + + it('should render templates in arrays', () => { + const renderer = new TemplateRenderer(); + const hass = createHASS(); + + const result = renderer.renderRecursively( + hass, + ['{{ acc.camera }}', 'static', '{{ acc.view }}'], + { conditionState: { camera: 'camera.office', view: 'live' } }, + ); + expect(result).toEqual(['camera.office', 'static', 'live']); + }); + + it('should render templates in object values', () => { + const renderer = new TemplateRenderer(); + const hass = createHASS(); + + const result = renderer.renderRecursively( + hass, + { camera: '{{ acc.camera }}', view: '{{ acc.view }}', static: 'value' }, + { conditionState: { camera: 'camera.office', view: 'live' } }, + ); + expect(result).toEqual({ camera: 'camera.office', view: 'live', static: 'value' }); + }); + + it('should render templates in nested objects', () => { + const renderer = new TemplateRenderer(); + const hass = createHASS(); + + const result = renderer.renderRecursively( + hass, + { outer: { inner: '{{ acc.camera }}' } }, + { conditionState: { camera: 'camera.office' } }, + ); + expect(result).toEqual({ outer: { inner: 'camera.office' } }); + }); + + it('should return non-string/array/object values unchanged', () => { + const renderer = new TemplateRenderer(); + const hass = createHASS(); + + expect(renderer.renderRecursively(hass, 42)).toBe(42); + expect(renderer.renderRecursively(hass, true)).toBe(true); + expect(renderer.renderRecursively(hass, null)).toBe(null); + }); + + it('should render strings without templates unchanged', () => { + const renderer = new TemplateRenderer(); + const hass = createHASS(); + + expect(renderer.renderRecursively(hass, 'hello world')).toBe('hello world'); + }); + + it('should render with triggerData context', () => { + const renderer = new TemplateRenderer(); + const hass = createHASS(); + + const result = renderer.renderRecursively(hass, '{{ acc.trigger.camera.to }}', { + triggerData: { camera: { from: 'camera.front', to: 'camera.backyard' } }, + }); + expect(result).toBe('camera.backyard'); + }); + + it('should render with mediaData context', () => { + const renderer = new TemplateRenderer(); + const hass = createHASS(); + + const result = renderer.renderRecursively( + hass, + 'Title: {{ acc.media.title }}, Folder: {{ acc.media.is_folder }}', + { + mediaData: { title: 'Test Media', is_folder: false }, + }, + ); + expect(result).toBe('Title: Test Media, Folder: false'); + }); + + it('should render with combined context options', () => { + const renderer = new TemplateRenderer(); + const hass = createHASS(); + + const result = renderer.renderRecursively( + hass, + '{{ acc.camera }} - {{ acc.media.title }}', + { + conditionState: { camera: 'camera.office' }, + mediaData: { title: 'My Video', is_folder: false }, + }, + ); + expect(result).toBe('camera.office - My Video'); + }); + + it('should return undefined context when no options provided', () => { + const renderer = new TemplateRenderer(); + const hass = createHASS(); + + // Without options, templates referencing acc should render empty. + const result = renderer.renderRecursively(hass, 'Value: {{ acc.camera }}'); + expect(result).toBe('Value:'); + }); + + it('should return undefined context when options have no relevant data', () => { + const renderer = new TemplateRenderer(); + const hass = createHASS(); + + // Empty conditionState without camera or view should not create context. + const result = renderer.renderRecursively(hass, 'Value: {{ acc.camera }}', { + conditionState: {}, + }); + expect(result).toBe('Value:'); + }); + }); +}); diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index 3198df9c..3eda3cdd 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -183,16 +183,20 @@ describe('TriggersManager', () => { describe('media', () => { it.each([ - [false, false, null], - [false, true, 'clip' as const], - [true, false, 'snapshot' as const], - [true, true, 'clip' as const], + // [review, snapshot, clip, expectedView] + [false, false, false, null], + [false, false, true, 'clip' as const], + [false, true, false, 'snapshot' as const], + [false, true, true, 'clip' as const], + [true, false, false, 'review' as const], + [true, true, true, 'review' as const], ])( - 'with snapshot %s and clip %s', + 'with review %s, snapshot %s and clip %s', async ( + hasReview: boolean, hasSnapshot: boolean, hasClip: boolean, - viewName: 'clip' | 'snapshot' | null, + viewName: 'review' | 'clip' | 'snapshot' | null, ) => { const api = createTriggerAPI({ config: { @@ -209,6 +213,7 @@ describe('TriggersManager', () => { cameraID: 'camera_1', type: 'new', fidelity: 'high', + review: hasReview, snapshot: hasSnapshot, clip: hasClip, }); diff --git a/tests/card-controller/view/item-manager.test.ts b/tests/card-controller/view/item-manager.test.ts index 53a80120..c9cf1e3d 100644 --- a/tests/card-controller/view/item-manager.test.ts +++ b/tests/card-controller/view/item-manager.test.ts @@ -226,7 +226,7 @@ describe('ViewItemManager', () => { vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = new ViewItemManager(api); - const item = new ViewFolder(createFolder()); + const item = new ViewFolder(createFolder(), []); vi.mocked(api.getFoldersManager().getDownloadPath).mockResolvedValue({ sign: false, @@ -242,7 +242,7 @@ describe('ViewItemManager', () => { vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = new ViewItemManager(api); - const item = new ViewFolder(createFolder(), { title: 'title' }); + const item = new ViewFolder(createFolder(), [], { title: 'title' }); vi.mocked(api.getFoldersManager().getDownloadPath).mockResolvedValue({ sign: false, @@ -284,4 +284,34 @@ describe('ViewItemManager', () => { expect(api.getFoldersManager().favorite).toBeCalledWith(item, true); }); }); + + describe('reviewMedia', () => { + it('should review camera media', async () => { + const api = createCardAPI(); + + const manager = new ViewItemManager(api); + const item = new TestViewMedia({ + cameraID: 'camera.office', + mediaType: 'review' as ViewMediaType, + }); + + await manager.reviewMedia(item, true); + + expect(api.getCameraManager().reviewMedia).toBeCalledWith(item, true); + }); + + it('should not review non-review media', async () => { + const api = createCardAPI(); + + const manager = new ViewItemManager(api); + const item = new TestViewMedia({ + cameraID: 'camera.office', + mediaType: ViewMediaType.Clip, + }); + + await manager.reviewMedia(item, true); + + expect(api.getCameraManager().reviewMedia).not.toBeCalled(); + }); + }); }); diff --git a/tests/card-controller/view/modifiers/index.test.ts b/tests/card-controller/view/modifiers/index.test.ts index c1975274..88816926 100644 --- a/tests/card-controller/view/modifiers/index.test.ts +++ b/tests/card-controller/view/modifiers/index.test.ts @@ -2,14 +2,14 @@ import { expect, it } from 'vitest'; import { applyViewModifiers } from '../../../../src/card-controller/view/modifiers'; import { MergeContextViewModifier } from '../../../../src/card-controller/view/modifiers/merge-context'; import { SetQueryViewModifier } from '../../../../src/card-controller/view/modifiers/set-query'; -import { EventMediaQuery } from '../../../../src/view/query'; +import { UnifiedQuery } from '../../../../src/view/unified-query'; import { QueryResults } from '../../../../src/view/query-results'; import { createView } from '../../../test-utils'; it('should apply view modifiers', () => { const view = createView(); - const query = new EventMediaQuery(); + const query = new UnifiedQuery(); const queryResults = new QueryResults(); const context = { diff --git a/tests/card-controller/view/modifiers/set-query.test.ts b/tests/card-controller/view/modifiers/set-query.test.ts index d86b8a55..58e4dfa8 100644 --- a/tests/card-controller/view/modifiers/set-query.test.ts +++ b/tests/card-controller/view/modifiers/set-query.test.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest'; import { SetQueryViewModifier } from '../../../../src/card-controller/view/modifiers/set-query'; -import { EventMediaQuery } from '../../../../src/view/query'; import { QueryResults } from '../../../../src/view/query-results'; +import { UnifiedQuery } from '../../../../src/view/unified-query'; import { createView } from '../../../test-utils'; it('should do nothing without arguments', () => { @@ -16,7 +16,7 @@ it('should do nothing without arguments', () => { it('should set query and results', () => { const view = createView(); - const query = new EventMediaQuery(); + const query = new UnifiedQuery(); const queryResults = new QueryResults(); const modifier = new SetQueryViewModifier({ diff --git a/tests/card-controller/view/query-executor.test.ts b/tests/card-controller/view/query-executor.test.ts deleted file mode 100644 index d65ef1fa..00000000 --- a/tests/card-controller/view/query-executor.test.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { add } from 'date-fns'; -import { describe, expect, it, vi } from 'vitest'; -import { QueryType } from '../../../src/camera-manager/types'; -import { FolderQuery } from '../../../src/card-controller/folders/types'; -import { QueryExecutor } from '../../../src/card-controller/view/query-executor'; -import { ClipsOrSnapshotsOrAll } from '../../../src/types'; -import { EventMediaQuery, FolderViewQuery } from '../../../src/view/query'; -import { - TestViewMedia, - createCameraManager, - createCardAPI, - createFolder, - createStore, - generateViewMediaArray, -} from '../../test-utils'; -import { createPopulatedAPI } from './test-utils'; - -describe('executeDefaultEventQuery', () => { - it('should return null without cameras', async () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue(createStore()); - - const executor = new QueryExecutor(api); - expect( - await executor.executeDefaultEventQuery({ - cameraID: 'camera.office', - }), - ).toBeNull(); - }); - - it('should return null without queries', async () => { - const api = createPopulatedAPI(); - vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue(null); - - const executor = new QueryExecutor(api); - expect( - await executor.executeDefaultEventQuery({ - cameraID: 'camera.office', - }), - ).toBeNull(); - }); - - describe('should return query results for specified camera', async () => { - it.each([['all' as const], ['clips' as const], ['snapshots' as const], [undefined]])( - '%s', - async (mediaType?: ClipsOrSnapshotsOrAll) => { - const api = createPopulatedAPI(); - const media = generateViewMediaArray(); - const rawQueries = [ - { type: QueryType.Event as const, cameraIDs: new Set(['camera.office']) }, - ]; - vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue( - rawQueries, - ); - vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); - - const executor = new QueryExecutor(api); - const results = await executor.executeDefaultEventQuery({ - cameraID: 'camera.office', - eventsMediaType: mediaType, - }); - - expect(results?.query.getQuery()).toEqual(rawQueries); - expect(results?.queryResults.getResults()).toEqual(media); - expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith( - new Set(['camera.office']), - { - limit: 50, - ...(mediaType === 'clips' && { hasClip: true }), - ...(mediaType === 'snapshots' && { hasSnapshot: true }), - }, - ); - }, - ); - }); - - it('should return query results for all cameras', async () => { - const api = createPopulatedAPI(); - const media = generateViewMediaArray(); - const rawQueries = [ - { - type: QueryType.Event as const, - cameraIDs: new Set(['camera.office', 'camera.kitchen']), - }, - ]; - vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue( - rawQueries, - ); - vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); - - const executor = new QueryExecutor(api); - const results = await executor.executeDefaultEventQuery(); - - expect(results?.query.getQuery()).toEqual(rawQueries); - expect(results?.queryResults.getResults()).toEqual(media); - expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith( - new Set(['camera.office', 'camera.kitchen']), - { - limit: 50, - }, - ); - }); - - it('should return null when query returns null', async () => { - const api = createPopulatedAPI(); - const rawQueries = [ - { - type: QueryType.Event as const, - cameraIDs: new Set(['camera.office']), - }, - ]; - vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue( - rawQueries, - ); - vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(null); - - const executor = new QueryExecutor(api); - expect( - await executor.executeDefaultEventQuery({ cameraID: 'camera.office' }), - ).toBeNull(); - expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith( - new Set(['camera.office']), - { - limit: 50, - }, - ); - }); -}); - -describe('executeDefaultRecordingQuery', () => { - it('should return null without cameras', async () => { - const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue(createStore()); - - const executor = new QueryExecutor(api); - expect( - await executor.executeDefaultRecordingQuery({ - cameraID: 'camera.office', - }), - ).toBeNull(); - }); - - it('should return null without queries', async () => { - const api = createPopulatedAPI(); - vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue( - null, - ); - - const executor = new QueryExecutor(api); - expect( - await executor.executeDefaultRecordingQuery({ - cameraID: 'camera.office', - }), - ).toBeNull(); - }); - - it('should return query results for specified camera', async () => { - const api = createPopulatedAPI(); - const media = generateViewMediaArray(); - const rawQueries = [ - { type: QueryType.Recording as const, cameraIDs: new Set(['camera.office']) }, - ]; - vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue( - rawQueries, - ); - vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); - - const executor = new QueryExecutor(api); - const results = await executor.executeDefaultRecordingQuery({ - cameraID: 'camera.office', - }); - - expect(results?.query.getQuery()).toEqual(rawQueries); - expect(results?.queryResults.getResults()).toEqual(media); - expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith( - new Set(['camera.office']), - { - limit: 50, - }, - ); - }); - - it('should return query results for all cameras', async () => { - const api = createPopulatedAPI(); - const media = generateViewMediaArray(); - const rawQueries = [ - { - type: QueryType.Recording as const, - cameraIDs: new Set(['camera.office', 'camera.kitchen']), - }, - ]; - vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue( - rawQueries, - ); - vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); - - const executor = new QueryExecutor(api); - const results = await executor.executeDefaultRecordingQuery(); - - expect(results?.query.getQuery()).toEqual(rawQueries); - expect(results?.queryResults.getResults()).toEqual(media); - expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith( - new Set(['camera.office', 'camera.kitchen']), - { - limit: 50, - }, - ); - }); - - it('should return null when query returns null', async () => { - const api = createPopulatedAPI(); - const rawQueries = [ - { - type: QueryType.Recording as const, - cameraIDs: new Set(['camera.office']), - }, - ]; - vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue( - rawQueries, - ); - vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(null); - - const executor = new QueryExecutor(api); - expect( - await executor.executeDefaultRecordingQuery({ cameraID: 'camera.office' }), - ).toBeNull(); - expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith( - new Set(['camera.office']), - { - limit: 50, - }, - ); - }); -}); - -describe('executeQuery', () => { - it('should return null when query is empty', async () => { - const executor = new QueryExecutor(createCardAPI()); - expect(await executor.executeQuery(new EventMediaQuery())).toBeNull(); - }); - - describe('should handle result rejections', () => { - it('rejected', async () => { - const api = createPopulatedAPI(); - const media = generateViewMediaArray(); - vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); - - const query = new EventMediaQuery([ - { - type: QueryType.Event as const, - cameraIDs: new Set(['camera.office']), - }, - ]); - const executor = new QueryExecutor(api); - - expect( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - await executor.executeQuery(query, { rejectResults: (_) => true }), - ).toBeNull(); - }); - - it('not rejected', async () => { - const api = createPopulatedAPI(); - const media = generateViewMediaArray(); - vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); - - const query = new EventMediaQuery([ - { - type: QueryType.Event as const, - cameraIDs: new Set(['camera.office']), - }, - ]); - const executor = new QueryExecutor(api); - - expect( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - await executor.executeQuery(query, { rejectResults: (_) => false }), - ).not.toBeNull(); - }); - }); - - describe('should select', () => { - it('by id', async () => { - const api = createPopulatedAPI(); - const media = generateViewMediaArray({ - cameraIDs: ['camera.office'], - count: 100, - }); - vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); - - const query = new EventMediaQuery([ - { - type: QueryType.Event as const, - cameraIDs: new Set(['camera.office']), - }, - ]); - const executor = new QueryExecutor(api); - - const results = await executor.executeMediaQuery(query, { - selectResult: { id: 'id-camera.office-42' }, - }); - expect(results?.queryResults.getSelectedResult()?.getID()).toBe( - 'id-camera.office-42', - ); - }); - }); - - it('by func', async () => { - const api = createPopulatedAPI(); - const media = generateViewMediaArray({ - cameraIDs: ['camera.office'], - count: 100, - }); - vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); - - const query = new EventMediaQuery([ - { - type: QueryType.Event as const, - cameraIDs: new Set(['camera.office']), - }, - ]); - const executor = new QueryExecutor(api); - - const results = await executor.executeMediaQuery(query, { - selectResult: { func: (media) => media.getID() === 'id-camera.office-43' }, - }); - expect(results?.queryResults.getSelectedResult()?.getID()).toBe( - 'id-camera.office-43', - ); - }); - - it('by time', async () => { - const now = new Date('2024-07-21T19:09:37Z'); - - const api = createPopulatedAPI(); - const media = [ - new TestViewMedia({ - cameraID: 'camera.office', - id: 'id-camera.office-0', - startTime: now, - }), - new TestViewMedia({ - cameraID: 'camera.office', - id: 'id-camera.office-1', - startTime: add(now, { seconds: 1 }), - }), - new TestViewMedia({ - cameraID: 'camera.office', - id: 'id-camera.office-2', - startTime: add(now, { seconds: 2 }), - }), - ]; - vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media); - - const query = new EventMediaQuery([ - { - type: QueryType.Event as const, - cameraIDs: new Set(['camera.office']), - }, - ]); - const executor = new QueryExecutor(api); - - const results = await executor.executeMediaQuery(query, { - selectResult: { time: { time: add(now, { seconds: 1 }) } }, - }); - expect(results?.queryResults.getSelectedResult()?.getID()).toBe( - 'id-camera.office-1', - ); - }); - - describe('should handle folder query', () => { - it('should return null without raw query', async () => { - const executor = new QueryExecutor(createCardAPI()); - expect(await executor.executeQuery(new FolderViewQuery())).toBeNull(); - }); - - it('should return null when query expansion fails', async () => { - const api = createCardAPI(); - vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(createFolder()); - vi.mocked(api.getFoldersManager().expandFolder).mockResolvedValue(null); - - const executor = new QueryExecutor(api); - expect(await executor.executeFolderQuery()).toBeNull(); - }); - }); -}); - -describe('executeFolderQuery', () => { - it('should return null without folders', async () => { - const executor = new QueryExecutor(createCardAPI()); - expect(await executor.executeFolderQuery()).toBeNull(); - }); - - it('should execute query against first folder', async () => { - const api = createCardAPI(); - const items = [new TestViewMedia()]; - const folder = createFolder(); - const query: FolderQuery = { - folder, - path: [{ ha: { id: 'path' } }], - }; - vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(folder); - vi.mocked(api.getFoldersManager().generateDefaultFolderQuery).mockReturnValue(query); - vi.mocked(api.getFoldersManager().expandFolder).mockResolvedValue(items); - - const executor = new QueryExecutor(api); - const result = await executor.executeFolderQuery(); - - expect(result?.query.getQuery()).toEqual(query); - expect(result?.queryResults.getResults()).toEqual(items); - }); - - it('should return null without folder results', async () => { - const api = createCardAPI(); - const folder = createFolder(); - const query: FolderQuery = { - folder, - path: [{ ha: { id: 'path' } }], - }; - vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(folder); - vi.mocked(api.getFoldersManager().generateDefaultFolderQuery).mockReturnValue(query); - vi.mocked(api.getFoldersManager().expandFolder).mockResolvedValue(null); - - const executor = new QueryExecutor(api); - expect(await executor.executeFolderQuery()).toBeNull(); - }); -}); diff --git a/tests/card-controller/view/sort.test.ts b/tests/card-controller/view/sort.test.ts index e6611acb..2669547c 100644 --- a/tests/card-controller/view/sort.test.ts +++ b/tests/card-controller/view/sort.test.ts @@ -24,8 +24,8 @@ describe('sortMedia', () => { startTime: new Date('2023-04-29T14:27'), cameraID: 'camera-1', }); - const folder_1 = new ViewFolder(createFolder(), { id: 'folder_1' }); - const folder_2 = new ViewFolder(createFolder(), { id: 'folder_2' }); + const folder_1 = new ViewFolder(createFolder(), [], { id: 'folder_1' }); + const folder_2 = new ViewFolder(createFolder(), [], { id: 'folder_2' }); it('should sort sorted media', () => { const media = [media_1, media_2]; @@ -44,10 +44,10 @@ describe('sortMedia', () => { new TestViewMedia({ id: 'snake' }), new TestViewMedia({ id: 'zebra' }), new TestViewMedia({ id: 'aardvark' }), - new ViewFolder(folder, { id: 'folder' }), + new ViewFolder(folder, [], { id: 'folder' }), ]), ).toEqual([ - new ViewFolder(folder, { id: 'folder' }), + new ViewFolder(folder, [], { id: 'folder' }), new TestViewMedia({ id: 'aardvark' }), new TestViewMedia({ id: 'snake' }), new TestViewMedia({ id: 'zebra' }), diff --git a/tests/card-controller/view/test-utils.ts b/tests/card-controller/view/test-utils.ts index 8d67dfc5..a837a56b 100644 --- a/tests/card-controller/view/test-utils.ts +++ b/tests/card-controller/view/test-utils.ts @@ -13,31 +13,31 @@ export const createPopulatedAPI = ( config?: RawAdvancedCameraCardConfig, ): CardController => { const api = createCardAPI(); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); - vi.mocked(api.getCameraManager().getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera.office', - capabilities: createCapabilities({ - live: true, - snapshots: true, - clips: true, - recordings: true, - substream: true, - }), - }, - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - live: true, - snapshots: true, - clips: true, - recordings: true, - substream: true, - }), - }, - ]), - ); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ + live: true, + snapshots: true, + clips: true, + recordings: true, + reviews: true, + substream: true, + }), + }, + { + cameraID: 'camera.kitchen', + capabilities: createCapabilities({ + live: true, + snapshots: true, + clips: true, + recordings: true, + reviews: true, + substream: true, + }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config)); return api; }; diff --git a/tests/card-controller/view/view-manager.test.ts b/tests/card-controller/view/view-manager.test.ts index 633ddc2b..af74b76b 100644 --- a/tests/card-controller/view/view-manager.test.ts +++ b/tests/card-controller/view/view-manager.test.ts @@ -1,7 +1,6 @@ import { ViewContext } from 'view'; import { describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { QueryType } from '../../../src/camera-manager/types'; import { CardController } from '../../../src/card-controller/controller'; import { ViewFactory } from '../../../src/card-controller/view/factory'; import { SetQueryViewModifier } from '../../../src/card-controller/view/modifiers/set-query'; @@ -11,11 +10,12 @@ import { } from '../../../src/card-controller/view/types'; import { ViewManager } from '../../../src/card-controller/view/view-manager'; import { ViewQueryExecutor } from '../../../src/card-controller/view/view-query-executor'; +import { AdvancedCameraCardView } from '../../../src/config/schema/common/const'; import { ViewMedia, ViewMediaType } from '../../../src/view/item'; -import { EventMediaQuery } from '../../../src/view/query'; import { QueryResults } from '../../../src/view/query-results'; +import { UnifiedQuery } from '../../../src/view/unified-query'; import { View } from '../../../src/view/view'; -import { createCardAPI, createView } from '../../test-utils'; +import { createCardAPI, createEventQuery, createView } from '../../test-utils'; const createInitializedCardAPI = (initialized?: boolean): CardController => { const api = createCardAPI(); @@ -464,55 +464,12 @@ describe('should initialize', () => { }); }); -it('should adopt query and results when changing to gallery from viewer', async () => { - const baseView = createView({ - view: 'media', - camera: 'camera.office', - query: new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.office']), - hasClip: true, - }, - ]), - queryResults: new QueryResults(), - }); - - const viewFactory = mock(); - viewFactory.getViewDefault - .mockReturnValueOnce(baseView) - .mockReturnValueOnce(createView({ view: 'clips' })); - - const viewQueryExecutor = mock(); - viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]); - - const manager = new ViewManager(createInitializedCardAPI(), { - viewFactory: viewFactory, - viewQueryExecutor: viewQueryExecutor, - }); - - manager.setViewDefault(); - expect(manager.getView()?.is('media')).toBeTruthy(); - - await manager.setViewDefaultWithNewQuery({ - params: { - view: 'clips', - }, - }); - - expect(manager.getView()?.is('clips')).toBeTruthy(); - expect(manager.getView()?.query).toBe(baseView.query); - expect(manager.getView()?.queryResults).toBe(baseView.queryResults); - expect(viewQueryExecutor.getNewQueryModifiers).not.toHaveBeenCalled(); - expect(viewQueryExecutor.getExistingQueryModifiers).not.toHaveBeenCalled(); -}); - describe('should apply async view modifications', () => { it('should apply modifications successfully', async () => { const viewFactory = mock(); viewFactory.getViewDefault.mockReturnValue(createView({ view: 'live' })); - const query = new EventMediaQuery(); + const query = new UnifiedQuery(); const queryResults = new QueryResults(); const viewQueryExecutor = mock(); @@ -539,7 +496,7 @@ describe('should apply async view modifications', () => { const viewFactory = mock(); viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' })); - const query = new EventMediaQuery(); + const query = new UnifiedQuery(); const queryResults = new QueryResults(); const viewQueryExecutor = mock(); @@ -691,47 +648,68 @@ describe('should apply async view modifications', () => { expect(manager.getView()?.context?.loading?.query).toBe(100); }); }); -}); - -it('should adopt query and results when changing to gallery from viewer', async () => { - const baseView = createView({ - view: 'media', - camera: 'camera.office', - query: new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.office']), - hasClip: true, - }, - ]), - queryResults: new QueryResults(), - }); - const viewFactory = mock(); - viewFactory.getViewDefault - .mockReturnValueOnce(baseView) - .mockReturnValueOnce(createView({ view: 'clips' })); + describe('should adopt query results when useful', () => { + const testAdoption = async ( + originView: AdvancedCameraCardView | undefined, + targetViewName: AdvancedCameraCardView, + startViewName: AdvancedCameraCardView = 'media', + ): Promise => { + const startView = createView({ + view: startViewName, + camera: 'camera', + query: new UnifiedQuery([createEventQuery('c1')]), + queryResults: new QueryResults(), + context: originView ? { gallery: { originView } } : undefined, + }); + const targetView = createView({ + view: targetViewName, + camera: 'camera', + }); - const viewQueryExecutor = mock(); - viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]); + const viewFactory = mock(); + viewFactory.getViewDefault + .mockReturnValueOnce(startView) + .mockReturnValueOnce(targetView); - const manager = new ViewManager(createInitializedCardAPI(), { - viewFactory: viewFactory, - viewQueryExecutor: viewQueryExecutor, - }); + const viewQueryExecutor = mock(); + viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]); - manager.setViewDefault(); - expect(manager.getView()?.is('media')).toBeTruthy(); + const manager = new ViewManager(createInitializedCardAPI(), { + viewFactory: viewFactory, + viewQueryExecutor: viewQueryExecutor, + }); - await manager.setViewDefaultWithNewQuery({ - params: { - view: 'clips', - }, - }); + manager.setViewDefault(); + await manager.setViewDefaultWithNewQuery(); + + return vi.mocked(viewQueryExecutor.getNewQueryModifiers).mock.calls.length === 0; + }; + + it('should return false when no originView context', async () => { + expect(await testAdoption(undefined, 'clips')).toBe(false); + }); - expect(manager.getView()?.is('clips')).toBeTruthy(); - expect(manager.getView()?.query).toBe(baseView.query); - expect(manager.getView()?.queryResults).toBe(baseView.queryResults); - expect(viewQueryExecutor.getNewQueryModifiers).not.toHaveBeenCalled(); - expect(viewQueryExecutor.getExistingQueryModifiers).not.toHaveBeenCalled(); + it('should return true when originView matches target', async () => { + expect(await testAdoption('clips', 'clips')).toBe(true); + expect(await testAdoption('snapshots', 'snapshots')).toBe(true); + expect(await testAdoption('recordings', 'recordings')).toBe(true); + expect(await testAdoption('reviews', 'reviews')).toBe(true); + }); + + it('should return false when originView does not match target', async () => { + expect(await testAdoption('clips', 'snapshots')).toBe(false); + expect(await testAdoption('clips', 'recordings')).toBe(false); + expect(await testAdoption('snapshots', 'clips')).toBe(false); + expect(await testAdoption('recordings', 'reviews')).toBe(false); + }); + + it('should return false when start view is not viewer', async () => { + expect(await testAdoption('clips', 'clips', 'live')).toBe(false); + }); + + it('should return false when target view is not gallery', async () => { + expect(await testAdoption('clips', 'live')).toBe(false); + }); + }); }); diff --git a/tests/card-controller/view/view-query-executor.test.ts b/tests/card-controller/view/view-query-executor.test.ts index 48b7332f..79b33c37 100644 --- a/tests/card-controller/view/view-query-executor.test.ts +++ b/tests/card-controller/view/view-query-executor.test.ts @@ -1,65 +1,186 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; +import { afterAll, assert, beforeAll, describe, expect, it, vi } from 'vitest'; +import { Capabilities } from '../../../src/camera-manager/capabilities'; +import { EventQuery, QueryType } from '../../../src/camera-manager/types'; import { applyViewModifiers } from '../../../src/card-controller/view/modifiers'; -import { QueryExecutor } from '../../../src/card-controller/view/query-executor'; import { ViewQueryExecutor } from '../../../src/card-controller/view/view-query-executor'; import { AdvancedCameraCardView } from '../../../src/config/schema/common/const'; -import { - EventMediaQuery, - FolderViewQuery, - RecordingMediaQuery, -} from '../../../src/view/query'; -import { QueryResults } from '../../../src/view/query-results'; +import { PerformanceConfig } from '../../../src/config/schema/performance'; +import { QuerySource } from '../../../src/query-source'; +import { UnifiedQuery } from '../../../src/view/unified-query'; import { View } from '../../../src/view/view'; -import { createCardAPI, createView } from '../../test-utils'; +import { + createCameraConfig, + createCameraManager, + createCapabilities, + createCardAPI, + createConfig, + createPerformanceConfig, + createStore, + createView, + isEventQuery, + TestViewMedia, +} from '../../test-utils'; import { createPopulatedAPI } from './test-utils'; describe('ViewQueryExecutor', () => { describe('getExistingQueryModifiers', () => { it('should return modifier with result when query present', async () => { - const executor = mock(); - const viewQueryExecutor = new ViewQueryExecutor(createCardAPI(), executor); + const api = createPopulatedAPI(); + const viewQueryExecutor = new ViewQueryExecutor(api); - const query = new EventMediaQuery(); - const queryResults = new QueryResults(); + const query = new UnifiedQuery(); const view = createView({ query: query, }); - executor.executeQuery.mockResolvedValue({ query, queryResults }); - - const queryExecutorOptions = { useCache: true }; - const modifiers = await viewQueryExecutor.getExistingQueryModifiers( - view, - queryExecutorOptions, - ); + const options = { useCache: true }; + const modifiers = await viewQueryExecutor.getExistingQueryModifiers(view, options); applyViewModifiers(view, modifiers); expect(view.query).toBe(query); - expect(view.queryResults).toBe(queryResults); - expect(executor.executeQuery).toBeCalledWith(query, queryExecutorOptions); + expect(view.queryResults).toBeDefined(); }); it('should not return modifier when query absent', async () => { - const executor = mock(); - const viewQueryExecutor = new ViewQueryExecutor(createCardAPI(), executor); + const api = createPopulatedAPI(); + const viewQueryExecutor = new ViewQueryExecutor(api); const view = createView(); - const queryExecutorOptions = {}; - const modifiers = await viewQueryExecutor.getExistingQueryModifiers( - view, - queryExecutorOptions, - ); + const options = {}; + const modifiers = await viewQueryExecutor.getExistingQueryModifiers(view, options); - expect(modifiers?.length).toBe(0); + expect(modifiers).toBeNull(); applyViewModifiers(view, modifiers); expect(view.query).toBeNull(); expect(view.queryResults).toBeNull(); - expect(executor.executeMediaQuery).not.toBeCalled(); + }); + + it('should return null when rejectResults returns true', async () => { + const api = createPopulatedAPI(); + const viewQueryExecutor = new ViewQueryExecutor(api); + + const query = new UnifiedQuery(); + const view = createView({ query }); + + const modifiers = await viewQueryExecutor.getExistingQueryModifiers(view, { + rejectResults: () => true, + }); + + expect(modifiers).toBeNull(); + }); + + it('should select result by id', async () => { + const api = createPopulatedAPI(); + const viewQueryExecutor = new ViewQueryExecutor(api); + + const media = new TestViewMedia({ id: 'test-id' }); + + // Mock the camera manager to return our test media when query is executed + const cameraManager = api.getCameraManager(); + if (cameraManager) { + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue([media]); + } + + const query = new UnifiedQuery(); + const eventQuery: EventQuery = { + source: QuerySource.Camera, + type: QueryType.Event, + cameraIDs: new Set(['camera.office']), + hasClip: true, + }; + query.addNode(eventQuery); + + const view = new View({ + view: 'clips', + camera: 'camera.office', + query, + }); + + const modifiers = await viewQueryExecutor.getExistingQueryModifiers(view, { + selectResult: { id: 'test-id' }, + }); + + applyViewModifiers(view, modifiers); + + expect(view.queryResults).not.toBeNull(); + expect(view.queryResults?.getSelectedResult()?.getID()).toBe('test-id'); + }); + + it('should select result by func', async () => { + const api = createPopulatedAPI(); + const viewQueryExecutor = new ViewQueryExecutor(api); + + const media = new TestViewMedia({ id: 'test-id' }); + + const cameraManager = api.getCameraManager(); + if (cameraManager) { + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue([media]); + } + + const query = new UnifiedQuery(); + const eventQuery: EventQuery = { + source: QuerySource.Camera, + type: QueryType.Event, + cameraIDs: new Set(['camera.office']), + hasClip: true, + }; + query.addNode(eventQuery); + + const view = new View({ + view: 'clips', + camera: 'camera.office', + query, + }); + + const modifiers = await viewQueryExecutor.getExistingQueryModifiers(view, { + selectResult: { func: (item) => item.getID() === 'test-id' }, + }); + + applyViewModifiers(view, modifiers); + + expect(view.queryResults).not.toBeNull(); + expect(view.queryResults?.getSelectedResult()?.getID()).toBe('test-id'); + }); + + it('should select result by time', async () => { + const api = createPopulatedAPI(); + const viewQueryExecutor = new ViewQueryExecutor(api); + + const now = new Date(); + const media = new TestViewMedia({ id: 'test-id', startTime: now }); + + const cameraManager = api.getCameraManager(); + if (cameraManager) { + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue([media]); + } + + const query = new UnifiedQuery(); + const eventQuery: EventQuery = { + source: QuerySource.Camera, + type: QueryType.Event, + cameraIDs: new Set(['camera.office']), + hasClip: true, + }; + query.addNode(eventQuery); + + const view = new View({ + view: 'clips', + camera: 'camera.office', + query, + }); + + const modifiers = await viewQueryExecutor.getExistingQueryModifiers(view, { + selectResult: { time: { time: now } }, + }); + + applyViewModifiers(view, modifiers); + + expect(view.queryResults).not.toBeNull(); + expect(view.queryResults?.getSelectedResult()?.getID()).toBe('test-id'); }); }); @@ -79,95 +200,297 @@ describe('ViewQueryExecutor', () => { vi.useRealTimers(); }); - it('should set query and queryResults for events', async () => { - const query = new EventMediaQuery(); - const queryResults = new QueryResults(); - - const executor = mock(); - executor.executeDefaultEventQuery.mockResolvedValue({ - query: query, - queryResults: queryResults, + it('should query all cameras in live view grid mode', async () => { + const api = createPopulatedAPI(); + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = createView({ + view: 'live', + camera: 'camera.office', + displayMode: 'grid', }); - const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor); - const view = createView({ view: 'live', camera: 'camera.office' }); - const queryExecutorOptions = {}; - - const modifiers = await viewQueryExecutor.getNewQueryModifiers( - view, - queryExecutorOptions, - ); + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); applyViewModifiers(view, modifiers); - expect(view?.query).toBe(query); - expect(view?.queryResults).toBe(queryResults); - expect(executor.executeDefaultEventQuery).toBeCalledWith({ - cameraID: 'camera.office', - eventsMediaType: 'all', - executorOptions: { - useCache: false, - }, - }); - expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); - expect(executor.executeFolderQuery).not.toHaveBeenCalled(); + expect(view.query).toBeInstanceOf(UnifiedQuery); + const allCameraIDs = view.query?.getAllCameraIDs(); + expect(allCameraIDs?.size).toBeGreaterThan(1); }); - it('should set query and queryResults for recordings', async () => { - const query = new RecordingMediaQuery(); - const queryResults = new QueryResults(); - - const executor = mock(); - executor.executeDefaultRecordingQuery.mockResolvedValue({ - query: query, - queryResults: queryResults, - }); - + it('should not fetch anything if configured for no thumbnails', async () => { const viewQueryExecutor = new ViewQueryExecutor( createPopulatedAPI({ live: { controls: { thumbnails: { - media_type: 'recordings', + mode: 'none' as const, }, }, }, }), - executor, ); - const view = createView({ view: 'live', camera: 'camera.office' }); - const queryExecutorOptions = {}; - const modifiers = await viewQueryExecutor.getNewQueryModifiers( - view, - queryExecutorOptions, - ); + const view = createView({ + view: 'live', + camera: 'camera.office', + }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); applyViewModifiers(view, modifiers); - expect(view?.query).toBe(query); - expect(view?.queryResults).toBe(queryResults); - expect(executor.executeDefaultRecordingQuery).toBeCalledWith({ - cameraID: 'camera.office', - executorOptions: { - useCache: false, - }, - }); - expect(executor.executeDefaultEventQuery).not.toBeCalled(); - expect(executor.executeFolderQuery).not.toHaveBeenCalled(); + expect(view.query).toBeNull(); + expect(view.queryResults).toBeNull(); }); - describe('should set timeline window', async () => { - it('should set timeline to now for live views', async () => { - const executor = mock(); - const viewQueryExecutor = new ViewQueryExecutor( - createPopulatedAPI(), - executor, - ); + describe('media type resolution', () => { + it.each([ + ['snapshots' as const, QueryType.Event], + ['clips' as const, QueryType.Event], + ['reviews' as const, QueryType.Review], + ])( + 'should resolve camera media config to type %s', + async (mode: 'snapshots' | 'clips' | 'reviews', queryType: QueryType) => { + const api = createPopulatedAPI(); + const cameraManager = api.getCameraManager(); + if (cameraManager) { + // Set up camera config with media type using createCameraConfig for defaults + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ [mode]: true }), + config: createCameraConfig({ + media: { + type: mode === 'reviews' ? 'reviews' : 'events', + ...(mode === 'clips' || mode === 'snapshots' + ? { events_type: mode } + : {}), + }, + }), + }, + ]); + vi.mocked(cameraManager.getStore).mockReturnValue(store); + vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( + new Capabilities({ [mode]: true }), + ); + } + + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = createView({ view: 'live', camera: 'camera.office' }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + applyViewModifiers(view, modifiers); + + expect(view.query).toBeDefined(); + const queries = view.query?.getMediaQueries({ type: queryType }); + const query = queries?.[0]; + assert(query); + + if (mode === 'snapshots') { + assert(isEventQuery(query)); + expect(query.hasSnapshot).toBe(true); + } else if (mode === 'clips') { + assert(isEventQuery(query)); + expect(query.hasClip).toBe(true); + } + }, + ); + + it('should handle folder type by returning null (folders handled separately)', async () => { + const api = createPopulatedAPI(); + const cameraManager = api.getCameraManager(); + if (cameraManager) { + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({}), + config: createCameraConfig({ + media: { + type: 'folder', + folders: ['folder-1'], + }, + }), + }, + ]); + vi.mocked(cameraManager.getStore).mockReturnValue(store); + } + + const viewQueryExecutor = new ViewQueryExecutor(api); const view = createView({ view: 'live', camera: 'camera.office' }); const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); applyViewModifiers(view, modifiers); - expect(view?.context).toEqual({ + // Folder type returns null and is handled separately + expect(view.query).toBeNull(); + }); + }); + + describe('auto-resolution', () => { + it('should auto-resolve to reviews if available', async () => { + const api = createPopulatedAPI(); + const cameraManager = api.getCameraManager(); + if (cameraManager) { + vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( + new Capabilities({ reviews: true }), + ); + } + + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = createView({ view: 'live', camera: 'camera.office' }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + + applyViewModifiers(view, modifiers); + + expect(view.query).toBeInstanceOf(UnifiedQuery); + const queries = view.query?.getMediaQueries({ type: QueryType.Review }); + expect(queries?.length).toBeGreaterThan(0); + expect(queries?.[0].cameraIDs.has('camera.office')).toBe(true); + expect(view.queryResults).toBeDefined(); + }); + + it('should auto-resolve to events if clips/snapshots are available', async () => { + const api = createPopulatedAPI(); + const cameraManager = api.getCameraManager(); + if (cameraManager) { + vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( + new Capabilities({ clips: true, snapshots: true }), + ); + } + + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = createView({ view: 'live', camera: 'camera.office' }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + + applyViewModifiers(view, modifiers); + + expect(view.query).toBeInstanceOf(UnifiedQuery); + const queries = view.query?.getMediaQueries({ type: QueryType.Event }); + expect(queries?.length).toBeGreaterThan(0); + }); + + it('should auto-resolve to recordings if only recordings is available', async () => { + const api = createPopulatedAPI(); + const cameraManager = api.getCameraManager(); + if (cameraManager) { + vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( + new Capabilities({ recordings: true }), + ); + } + + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = createView({ view: 'live', camera: 'camera.office' }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + applyViewModifiers(view, modifiers); + + // Recordings-only cameras auto-resolve to recordings + expect(view.query).toBeDefined(); + const queries = view.query?.getMediaQueries({ type: QueryType.Recording }); + expect(queries?.length).toBeGreaterThan(0); + }); + + it('should auto-resolve to null if no capabilities match', async () => { + const api = createPopulatedAPI(); + const cameraManager = api.getCameraManager(); + if (cameraManager) { + vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( + new Capabilities({}), + ); + } + + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = createView({ view: 'live', camera: 'camera.office' }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + + applyViewModifiers(view, modifiers); + expect(view.query).toBeNull(); + }); + + it('should use events_media_type when camera does not have reviews capability', async () => { + const api = createPopulatedAPI(); + const cameraManager = api.getCameraManager(); + if (cameraManager) { + vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( + new Capabilities({ clips: true, reviews: false }), + ); + } + + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = createView({ view: 'live', camera: 'camera.office' }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + + applyViewModifiers(view, modifiers); + + expect(view.query).toBeInstanceOf(UnifiedQuery); + const queries = view.query?.getMediaQueries({ type: QueryType.Event }); + expect(queries?.length).toBeGreaterThan(0); + }); + }); + + describe('when setting or removing seek time', () => { + it('should set seek time when results are selected based on time', async () => { + const now = new Date(); + const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI()); + + const view = new View({ + view: 'clip', + camera: 'camera.office', + }); + + const queryExecutorOptions = { + selectResult: { + time: { + time: now, + }, + }, + }; + + const modifiers = await viewQueryExecutor.getNewQueryModifiers( + view, + queryExecutorOptions, + ); + applyViewModifiers(view, modifiers); + + expect(view.context).toEqual({ + mediaViewer: { + seek: now, + }, + }); + }); + + it('should remove seek time when results are not selected based on time', async () => { + const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI()); + + const view = new View({ + view: 'clip', + camera: 'camera.office', + context: { + mediaViewer: { + seek: new Date(), + }, + }, + }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + applyViewModifiers(view, modifiers); + + expect(view.context?.mediaViewer?.seek).toBeUndefined(); + }); + }); + + describe('timeline window', async () => { + it('should set timeline to now for live views', async () => { + const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI()); + const view = createView({ view: 'live', camera: 'camera.office' }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + applyViewModifiers(view, modifiers); + + expect(view.context).toEqual({ timeline: { window: { start: new Date('2024-07-21T12:22:06.000Z'), @@ -178,11 +501,7 @@ describe('ViewQueryExecutor', () => { }); it('should unset timeline for non-live views', async () => { - const executor = mock(); - const viewQueryExecutor = new ViewQueryExecutor( - createPopulatedAPI(), - executor, - ); + const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI()); const view = createView({ view: 'clips', camera: 'camera.office', @@ -199,12 +518,11 @@ describe('ViewQueryExecutor', () => { const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); applyViewModifiers(view, modifiers); - expect(view?.context).toEqual({ timeline: {} }); + expect(view.context).toEqual({ timeline: {} }); }); }); it('should not fetch anything if configured for no thumbnails', async () => { - const executor = mock(); const viewQueryExecutor = new ViewQueryExecutor( createPopulatedAPI({ live: { @@ -215,7 +533,6 @@ describe('ViewQueryExecutor', () => { }, }, }), - executor, ); const view = createView({ @@ -226,26 +543,42 @@ describe('ViewQueryExecutor', () => { const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); applyViewModifiers(view, modifiers); - expect(view?.query).toBeNull(); - expect(view?.queryResults).toBeNull(); - expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled(); - expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); - expect(executor.executeFolderQuery).not.toHaveBeenCalled(); + expect(view.query).toBeNull(); + expect(view.queryResults).toBeNull(); + }); + }); + + describe('with a timeline view', () => { + it('should query all cameras for timeline view', async () => { + const api = createPopulatedAPI(); + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = createView({ + view: 'timeline', + camera: 'camera.office', + }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + applyViewModifiers(view, modifiers); + + expect(view.query).toBeInstanceOf(UnifiedQuery); + + // Timeline view should query all cameras, not just the current camera. + const allCameraIDs = view.query?.getAllCameraIDs(); + expect(allCameraIDs?.size).toBeGreaterThan(1); }); }); describe('with a media view', () => { it('should set query and queryResults for events', async () => { - const executor = mock(); - const query = new EventMediaQuery(); - const queryResults = new QueryResults(); + const api = createPopulatedAPI(); + const cameraManager = api.getCameraManager(); + if (cameraManager) { + vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( + new Capabilities({ clips: true }), + ); + } - executor.executeDefaultEventQuery.mockResolvedValue({ - query: query, - queryResults: queryResults, - }); - - const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor); + const viewQueryExecutor = new ViewQueryExecutor(api); const view = new View({ view: 'media', camera: 'camera.office', @@ -254,85 +587,190 @@ describe('ViewQueryExecutor', () => { const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); applyViewModifiers(view, modifiers); - expect(view?.query).toBe(query); - expect(view?.queryResults).toBe(queryResults); - expect(executor.executeDefaultEventQuery).toBeCalledWith({ - cameraID: 'camera.office', - eventsMediaType: 'clips', - executorOptions: { - useCache: false, - }, + expect(view.query).toBeInstanceOf(UnifiedQuery); + const queries = view.query?.getMediaQueries({ type: QueryType.Event }); + expect(queries?.length).toBeGreaterThan(0); + expect(queries?.[0].cameraIDs.has('camera.office')).toBe(true); + expect(view.queryResults).toBeDefined(); + }); + + it('should query all cameras in media view grid mode', async () => { + const api = createPopulatedAPI(); + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = createView({ + view: 'media', + camera: 'camera.office', + displayMode: 'grid', }); - expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); - expect(executor.executeFolderQuery).not.toHaveBeenCalled(); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + applyViewModifiers(view, modifiers); + + expect(view.query).toBeInstanceOf(UnifiedQuery); + const allCameraIDs = view.query?.getAllCameraIDs(); + expect(allCameraIDs?.size).toBeGreaterThan(0); }); }); describe('with an events-based view', () => { it.each([ - ['clip' as const, 'clips' as const], - ['clips' as const, 'clips' as const], - ['snapshot' as const, 'snapshots' as const], - ['snapshots' as const, 'snapshots' as const], - ])( - '%s', - async ( - viewName: AdvancedCameraCardView, - eventsMediaType: 'clips' | 'snapshots', - ) => { - const executor = mock(); - const query = new EventMediaQuery(); - const queryResults = new QueryResults(); - - executor.executeDefaultEventQuery.mockResolvedValue({ - query: query, - queryResults: queryResults, - }); - - const viewQueryExecutor = new ViewQueryExecutor( - createPopulatedAPI(), - executor, + 'clip' as const, + 'clips' as const, + 'snapshot' as const, + 'snapshots' as const, + ])('%s', async (viewName: AdvancedCameraCardView) => { + const api = createPopulatedAPI(); + const cameraManager = api.getCameraManager(); + if (cameraManager) { + vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( + new Capabilities({ clips: true, snapshots: true }), ); - const view = new View({ - view: viewName, - camera: 'camera.office', - }); + } - const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); - applyViewModifiers(view, modifiers); + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = new View({ + view: viewName, + camera: 'camera.office', + }); - expect(view?.query).toBe(query); - expect(view?.queryResults).toBe(queryResults); - expect(executor.executeDefaultEventQuery).toBeCalledWith({ + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + + applyViewModifiers(view, modifiers); + + expect(view.query).toBeInstanceOf(UnifiedQuery); + const queries = view.query?.getMediaQueries({ type: QueryType.Event }); + + expect(queries?.length).toBeGreaterThan(0); + expect(queries?.[0].cameraIDs.has('camera.office')).toBe(true); + }); + + it('should return empty modifiers when no cameras have capability', async () => { + const api = createCardAPI(); + const store = createStore([ + { cameraID: 'camera.office', - eventsMediaType: eventsMediaType, - executorOptions: { - useCache: false, + + // No clips/snapshots + capabilities: createCapabilities({ live: true }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = new View({ + view: 'clips', + camera: 'camera.office', + }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + + applyViewModifiers(view, modifiers); + + expect(view.query).toBeNull(); + }); + + it('should query all cameras when in grid mode', async () => { + const api = createPopulatedAPI(); + + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = new View({ + view: 'clips', + camera: 'camera.office', + displayMode: 'grid', + }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + + applyViewModifiers(view, modifiers); + + expect(view.query).toBeInstanceOf(UnifiedQuery); + + const allCameraIDs = view.query?.getAllCameraIDs(); + expect(allCameraIDs?.size).toBeGreaterThan(0); + }); + + it('should apply media_chunk_size to query limit', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ clips: true }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue({ + ...createConfig(), + performance: createPerformanceConfig({ + features: { + media_chunk_size: 42, }, - }); - expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); - expect(executor.executeFolderQuery).not.toHaveBeenCalled(); - }, - ); + }), + }); + + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = new View({ + view: 'clips', + camera: 'camera.office', + }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + + applyViewModifiers(view, modifiers); + + expect(view.query).toBeInstanceOf(UnifiedQuery); + const queries = view.query?.getMediaQueries({ type: QueryType.Event }); + expect(queries?.length).toBeGreaterThan(0); + expect(queries?.[0].limit).toBe(42); + }); + + it('should use default chunk size when config is missing', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ clips: true }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + + vi.mocked(api.getConfigManager().getConfig).mockReturnValue({ + ...createConfig(), + performance: undefined as unknown as PerformanceConfig, + }); + + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = new View({ + view: 'clips', + camera: 'camera.office', + }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + + applyViewModifiers(view, modifiers); + + // Should use default limit (MEDIA_CHUNK_SIZE_DEFAULT = 50) + expect(view.query).toBeInstanceOf(UnifiedQuery); + const queries = view.query?.getMediaQueries({ type: QueryType.Event }); + expect(queries?.length).toBeGreaterThan(0); + expect(queries?.[0].limit).toBe(50); + }); }); describe('with a recordings-based view', () => { it.each([['recording' as const], ['recordings' as const]])( '%s', async (viewName: AdvancedCameraCardView) => { - const executor = mock(); - const query = new RecordingMediaQuery(); - const queryResults = new QueryResults(); + const api = createPopulatedAPI(); + const cameraManager = api.getCameraManager(); + if (cameraManager) { + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue([]); + vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( + new Capabilities({ recordings: true }), + ); + } - executor.executeDefaultRecordingQuery.mockResolvedValue({ - query: query, - queryResults: queryResults, - }); - - const viewQueryExecutor = new ViewQueryExecutor( - createPopulatedAPI(), - executor, - ); + const viewQueryExecutor = new ViewQueryExecutor(api); const view = new View({ view: viewName, camera: 'camera.office', @@ -341,112 +779,112 @@ describe('ViewQueryExecutor', () => { const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); applyViewModifiers(view, modifiers); - expect(view?.query).toBe(query); - expect(view?.queryResults).toBe(queryResults); - expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled(); - expect(executor.executeDefaultRecordingQuery).toBeCalledWith({ - cameraID: 'camera.office', - executorOptions: { - useCache: false, - }, - }); - expect(executor.executeFolderQuery).not.toHaveBeenCalled(); + expect(view.query).toBeInstanceOf(UnifiedQuery); + const queries = view.query?.getMediaQueries({ type: QueryType.Recording }); + expect(queries?.length).toBeGreaterThan(0); + expect(queries?.[0].cameraIDs.has('camera.office')).toBe(true); + expect(view.queryResults).toBeDefined(); }, ); }); describe('with a folder view', () => { - it('should execute default folder query with folder view', async () => { - const executor = mock(); - const query = new FolderViewQuery(); - const queryResults = new QueryResults(); + it.each([['folder' as const], ['folders' as const]])( + 'should execute default folder query with %s view', + async (viewName: AdvancedCameraCardView) => { + const api = createPopulatedAPI(); + vi.mocked(api.getFoldersManager().getFolder).mockReturnValue({ + type: 'ha', + id: 'office', + title: 'Office', + }); + vi.mocked(api.getFoldersManager().getDefaultQueryParameters).mockReturnValue({ + source: QuerySource.Folder, + folder: { type: 'ha', id: 'office', title: 'Office' }, + path: [{ ha: { id: 'media-source://' } }], + }); - executor.executeFolderQuery.mockResolvedValue({ - query: query, - queryResults: queryResults, - }); + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = createView({ + view: viewName, + camera: 'camera.office', + }); - const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor); - const view = createView({ view: 'folder', camera: 'camera.office' }); + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + applyViewModifiers(view, modifiers); - const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); - applyViewModifiers(view, modifiers); - - expect(view?.query).toBe(query); - expect(view?.queryResults).toBe(queryResults); - expect(executor.executeDefaultEventQuery).not.toBeCalled(); - expect(executor.executeDefaultRecordingQuery).not.toBeCalled(); - expect(executor.executeFolderQuery).toBeCalledWith({ - useCache: false, - }); - }); + expect(view.query).toBeInstanceOf(UnifiedQuery); + const queries = view.query?.getFolderQueries(); + expect(queries?.length).toBeGreaterThan(0); + }, + ); it('should execute default folder query with folder view and handle null results', async () => { - const executor = mock(); - executor.executeFolderQuery.mockResolvedValue(null); + const api = createPopulatedAPI(); + vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(null); + vi.mocked(api.getFoldersManager().getDefaultQueryParameters).mockReturnValue( + null, + ); - const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor); + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = createView({ view: 'folder', camera: 'camera.office' }); + + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + + applyViewModifiers(view, modifiers); + + expect(view.query).toBeNull(); + }); + + it('should return empty when getDefaultQueryParameters returns null', async () => { + const api = createPopulatedAPI(); + vi.mocked(api.getFoldersManager().getFolder).mockReturnValue({ + type: 'ha', + id: 'office', + title: 'Office', + }); + vi.mocked(api.getFoldersManager().getDefaultQueryParameters).mockReturnValue( + null, + ); + + const viewQueryExecutor = new ViewQueryExecutor(api); const view = createView({ view: 'folder', camera: 'camera.office' }); const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); applyViewModifiers(view, modifiers); - expect(view?.query).toBeNull(); - expect(view?.queryResults).toBeNull(); + expect(view.query).toBeNull(); }); }); - describe('when setting or removing seek time', () => { - it('should set seek time when results are selected based on time', async () => { - const now = new Date(); - const executor = mock(); - const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor); + describe('with a reviews-based view', () => { + it.each([['review' as const], ['reviews' as const]])( + '%s', + async (viewName: AdvancedCameraCardView) => { + const api = createPopulatedAPI(); + const cameraManager = api.getCameraManager(); + if (cameraManager) { + vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( + new Capabilities({ reviews: true }), + ); + } - const view = new View({ - view: 'clip', - camera: 'camera.office', - }); + const viewQueryExecutor = new ViewQueryExecutor(api); + const view = new View({ + view: viewName, + camera: 'camera.office', + }); - const queryExecutorOptions = { - selectResult: { - time: { - time: now, - }, - }, - }; + const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); + applyViewModifiers(view, modifiers); - const modifiers = await viewQueryExecutor.getNewQueryModifiers( - view, - queryExecutorOptions, - ); - applyViewModifiers(view, modifiers); - - expect(view?.context).toEqual({ - mediaViewer: { - seek: now, - }, - }); - }); - - it('should remove seek time when results are not selected based on time', async () => { - const executor = mock(); - const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor); - - const view = new View({ - view: 'clip', - camera: 'camera.office', - context: { - mediaViewer: { - seek: new Date(), - }, - }, - }); - - const modifiers = await viewQueryExecutor.getNewQueryModifiers(view); - applyViewModifiers(view, modifiers); - - expect(view?.context?.mediaViewer?.seek).toBeUndefined(); - }); + expect(view.query).toBeInstanceOf(UnifiedQuery); + const queries = view.query?.getMediaQueries({ type: QueryType.Review }); + expect(queries?.length).toBeGreaterThan(0); + expect(queries?.[0].cameraIDs.has('camera.office')).toBe(true); + expect(view.queryResults).toBeDefined(); + }, + ); }); }); }); diff --git a/tests/cached-value-controller.test.ts b/tests/components-lib/cached-value-controller.test.ts similarity index 96% rename from tests/cached-value-controller.test.ts rename to tests/components-lib/cached-value-controller.test.ts index f3694cb1..3fbcff62 100644 --- a/tests/cached-value-controller.test.ts +++ b/tests/components-lib/cached-value-controller.test.ts @@ -1,7 +1,7 @@ import { ReactiveControllerHost } from 'lit'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { CachedValueController } from '../src/components-lib/cached-value-controller'; +import { CachedValueController } from '../../src/components-lib/cached-value-controller'; // @vitest-environment jsdom describe('CachedValueController', () => { diff --git a/tests/components-lib/folder/up-folder.test.ts b/tests/components-lib/folder/up-folder.test.ts deleted file mode 100644 index cd6d14bc..00000000 --- a/tests/components-lib/folder/up-folder.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { ViewManagerEpoch } from '../../../src/card-controller/view/types'; -import { - getUpFolderMediaItem, - upFolderClickHandler, -} from '../../../src/components-lib/folder/up-folder'; -import { ViewFolder } from '../../../src/view/item'; -import { EventMediaQuery, FolderViewQuery } from '../../../src/view/query'; -import { - createCardAPI, - createFolder, - createView, - TestViewMedia, -} from '../../test-utils'; - -describe('upFolderClickHandler', () => { - const item = new TestViewMedia(); - - it('should ignore non-folder query', () => { - const api = createCardAPI(); - const view = createView({ - query: new EventMediaQuery(), - }); - vi.mocked(api.getViewManager().getView).mockReturnValue(view); - - const epoch: ViewManagerEpoch = { - manager: api.getViewManager(), - }; - - upFolderClickHandler(item, new Event('click'), epoch); - - expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled(); - }); - - it('should ignore folder query without raw query', () => { - const api = createCardAPI(); - const view = createView({ - query: new FolderViewQuery(), - }); - vi.mocked(api.getViewManager().getView).mockReturnValue(view); - - const epoch: ViewManagerEpoch = { - manager: api.getViewManager(), - }; - - upFolderClickHandler(item, new Event('click'), epoch); - - expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled(); - }); - - it('should ignore folder query wihout parent to go up to', () => { - const api = createCardAPI(); - const view = createView({ - query: new FolderViewQuery({ folder: createFolder(), path: ['path'] }), - }); - vi.mocked(api.getViewManager().getView).mockReturnValue(view); - - const epoch: ViewManagerEpoch = { - manager: api.getViewManager(), - }; - - upFolderClickHandler(item, new Event('click'), epoch); - - expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled(); - }); - - it('should go up in the folder hierarchy', () => { - const api = createCardAPI(); - const folder = createFolder(); - const view = createView({ - query: new FolderViewQuery({ - folder, - path: ['one', 'two', 'three'], - }), - }); - vi.mocked(api.getViewManager().getView).mockReturnValue(view); - - const epoch: ViewManagerEpoch = { - manager: api.getViewManager(), - }; - - upFolderClickHandler(item, new Event('click'), epoch); - - expect(api.getViewManager().setViewByParametersWithExistingQuery).toBeCalledWith({ - params: { - query: expect.any(FolderViewQuery), - }, - }); - - const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery) - .mock.calls[0][0]?.params?.query; - expect(query?.getQuery()).toEqual({ - folder, - path: ['one', 'two'], - }); - }); -}); - -describe('getUpFolderMediaItem', () => { - it('should ignore non-folder query', () => { - const view = createView({ - query: new EventMediaQuery(), - }); - expect(getUpFolderMediaItem(view)).toBeNull(); - }); - - it('should ignore folder query without raw query', () => { - const view = createView({ - query: new FolderViewQuery(), - }); - expect(getUpFolderMediaItem(view)).toBeNull(); - }); - - it('should ignore folder query without parents', () => { - const view = createView({ - query: new FolderViewQuery({ folder: createFolder(), path: ['one'] }), - }); - expect(getUpFolderMediaItem(view)).toBeNull(); - }); - - it('should get up folder media', () => { - const view = createView({ - query: new FolderViewQuery({ - folder: createFolder(), - path: ['one', 'two', 'three'], - }), - }); - - const folderMedia = getUpFolderMediaItem(view); - - expect(folderMedia).toBeInstanceOf(ViewFolder); - expect(folderMedia?.getIcon()).toBe('mdi:arrow-up-left'); - }); -}); diff --git a/tests/components-lib/gallery/controller.test.ts b/tests/components-lib/gallery/controller.test.ts new file mode 100644 index 00000000..82a8191e --- /dev/null +++ b/tests/components-lib/gallery/controller.test.ts @@ -0,0 +1,322 @@ +import { assert, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { + ViewManagerEpoch, + ViewManagerInterface, +} from '../../../src/card-controller/view/types'; +import { GalleryController } from '../../../src/components-lib/gallery/controller'; +import { THUMBNAIL_WIDTH_DEFAULT } from '../../../src/config/schema/common/controls/thumbnails'; +import { FolderConfig } from '../../../src/config/schema/folders'; +import { MediaGalleryThumbnailsConfig } from '../../../src/config/schema/media-gallery'; +import { ViewFolder, ViewItem, ViewMedia, ViewMediaType } from '../../../src/view/item'; +import { QueryResults } from '../../../src/view/query-results'; +import { UnifiedQuery } from '../../../src/view/unified-query'; +import { UnifiedQueryRunner } from '../../../src/view/unified-query-runner'; +import { View } from '../../../src/view/view'; + +// @vitest-environment jsdom +const createThumbnailConfig = ( + config?: Partial, +): MediaGalleryThumbnailsConfig => ({ + size: THUMBNAIL_WIDTH_DEFAULT, + show_details: true, + show_favorite_control: true, + show_timeline_control: true, + show_download_control: true, + show_review_control: true, + show_info_control: true, + ...config, +}); + +describe('GalleryController', () => { + it('should construct', () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + expect(controller).toBeTruthy(); + expect(controller.getItems()).toBeNull(); + }); + + describe('setItemsFromView', () => { + it('should set items from view', () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + const view = mock(); + view.queryResults = mock(); + const item1 = new ViewMedia(ViewMediaType.Clip); + const item2 = new ViewMedia(ViewMediaType.Clip); + + vi.mocked(view.queryResults.getResults).mockReturnValue([item1, item2]); + + controller.setItemsFromView(view); + + // Items should be reversed (newest first) + expect(controller.getItems()).toEqual([item2, item1]); + }); + + it('should handle null view or results', () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + + controller.setItemsFromView(null); + expect(controller.getItems()).toBeNull(); + + const view = mock(); + view.queryResults = null; + controller.setItemsFromView(view); + expect(controller.getItems()).toBeNull(); + }); + + it('should not update items if results are the same', () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + const view = mock(); + view.queryResults = mock(); + const item1 = new ViewMedia(ViewMediaType.Clip); + + vi.mocked(view.queryResults.getResults).mockReturnValue([item1]); + + controller.setItemsFromView(view); + const itemsFirst = controller.getItems(); + + // Second call with same results object + controller.setItemsFromView(view, view); + expect(controller.getItems()).toBe(itemsFirst); + }); + + it('should update items if results change', () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + const view1 = mock(); + view1.queryResults = mock(); + const item1 = new ViewMedia(ViewMediaType.Clip); + vi.mocked(view1.queryResults.getResults).mockReturnValue([item1]); + + controller.setItemsFromView(view1); + + const view2 = mock(); + view2.queryResults = mock(); + const item2 = new ViewMedia(ViewMediaType.Clip); + vi.mocked(view2.queryResults.getResults).mockReturnValue([item2]); + + controller.setItemsFromView(view2, view1); + expect(controller.getItems()).toEqual([item2]); + }); + }); + + it('should set thumbnail size', () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + + controller.setThumbnailSize(150); + expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe( + '150px', + ); + + controller.setThumbnailSize(); + expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe( + `${THUMBNAIL_WIDTH_DEFAULT}px`, + ); + }); + + describe('getColumnWidth', () => { + it('should return default width if no config', () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + expect(controller.getColumnWidth()).toBe(THUMBNAIL_WIDTH_DEFAULT); + }); + + it('should return size if details are hidden', () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + expect( + controller.getColumnWidth( + createThumbnailConfig({ size: 123, show_details: false }), + ), + ).toBe(123); + }); + + it('should return gallery width if details are shown and items are not all folders', () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + const view = mock(); + const item = new ViewMedia(ViewMediaType.Clip); + + view.queryResults = mock(); + vi.mocked(view.queryResults.getResults).mockReturnValue([item]); + + controller.setItemsFromView(view); + expect( + controller.getColumnWidth(createThumbnailConfig({ show_details: true })), + ).toBe(300); + }); + + it('should return folder width if details are shown and items are all folders', () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + const view = mock(); + const item = new ViewFolder(mock(), []); + + view.queryResults = mock(); + vi.mocked(view.queryResults.getResults).mockReturnValue([item]); + + controller.setItemsFromView(view); + expect( + controller.getColumnWidth(createThumbnailConfig({ show_details: true })), + ).toBe(200); + }); + }); + + it('should get column count round method', () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + + expect( + controller.getColumnCountRoundMethod( + createThumbnailConfig({ show_details: true }), + ), + ).toBe('floor'); + expect( + controller.getColumnCountRoundMethod( + createThumbnailConfig({ show_details: false }), + ), + ).toBe('ceil'); + expect(controller.getColumnCountRoundMethod()).toBe('ceil'); + }); + + describe('extend', () => { + it('should handle missing view or query', async () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + const runner = mock(); + const manager = mock(); + const epoch = mock({ + manager: manager, + }); + + manager.getView.mockReturnValue(null); + await controller.extend(runner, epoch, 'earlier'); + expect(runner.extend).not.toHaveBeenCalled(); + + const view = mock(); + view.query = null; + manager.getView.mockReturnValue(view); + await controller.extend(runner, epoch, 'earlier'); + expect(runner.extend).not.toHaveBeenCalled(); + }); + + it('should handle missing results', async () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + const runner = mock(); + const manager = mock(); + const epoch = mock({ + manager: manager, + }); + const view = mock(); + + view.query = mock(); + view.queryResults = mock(); + vi.mocked(view.queryResults.getResults).mockReturnValue(null); + manager.getView.mockReturnValue(view); + + await controller.extend(runner, epoch, 'earlier'); + expect(runner.extend).not.toHaveBeenCalled(); + }); + + it('should extend and update view', async () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + const runner = mock(); + const manager = mock(); + const epoch = mock({ + manager: manager, + }); + const view = mock(); + const query = mock(); + const selectedResult = mock(); + const results = [selectedResult]; + + view.query = query; + view.queryResults = mock(); + vi.mocked(view.queryResults.getResults).mockReturnValue(results); + vi.mocked(view.queryResults.getSelectedResult).mockReturnValue(selectedResult); + manager.getView.mockReturnValue(view); + + const extendedQuery = mock(); + const extendedResults = [selectedResult, new ViewMedia(ViewMediaType.Clip)]; + runner.extend.mockResolvedValue({ + query: extendedQuery, + results: extendedResults, + }); + + await controller.extend(runner, epoch, 'earlier'); + + expect(runner.extend).toHaveBeenCalledWith(query, results, 'earlier', { + useCache: true, + }); + expect(manager.setViewByParameters).toHaveBeenCalled(); + + const setViewCalls = vi.mocked(manager.setViewByParameters).mock.calls; + const setViewParams = setViewCalls[0][0]; + assert(setViewParams && setViewParams.params); + + const newQueryResults = setViewParams.params.queryResults; + assert(newQueryResults); + expect(newQueryResults.getResults()).toEqual(extendedResults); + + expect(newQueryResults.getSelectedResult()).toBe(selectedResult); + }); + + it('should handle extend failure', async () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + const runner = mock(); + const manager = mock(); + const epoch = mock({ + manager: manager, + }); + const view = mock(); + + view.query = mock(); + view.queryResults = mock(); + vi.mocked(view.queryResults.getResults).mockReturnValue([ + new ViewMedia(ViewMediaType.Clip), + ]); + manager.getView.mockReturnValue(view); + + const error = new Error('test error'); + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + runner.extend.mockRejectedValue(error); + + await controller.extend(runner, epoch, 'earlier'); + + expect(spy).toHaveBeenCalledWith(error.message); + spy.mockRestore(); + expect(manager.setViewByParameters).not.toHaveBeenCalled(); + }); + + it('should not update view if extend returns null', async () => { + const host = document.createElement('div'); + const controller = new GalleryController(host); + const runner = mock(); + const manager = mock(); + const epoch = mock({ + manager: manager, + }); + const view = mock(); + + view.query = mock(); + view.queryResults = mock(); + vi.mocked(view.queryResults.getResults).mockReturnValue([ + new ViewMedia(ViewMediaType.Clip), + ]); + manager.getView.mockReturnValue(view); + + runner.extend.mockResolvedValue(null); + + await controller.extend(runner, epoch, 'earlier'); + + expect(manager.setViewByParameters).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/components-lib/gallery/folder-gallery-controller.test.ts b/tests/components-lib/gallery/folder-gallery-controller.test.ts deleted file mode 100644 index 0771c5bb..00000000 --- a/tests/components-lib/gallery/folder-gallery-controller.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import { ViewManagerInterface } from '../../../src/card-controller/view/types'; -import { FoldersManager } from '../../../src/card-controller/folders/manager'; -import { - FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN, - FolderGalleryController, -} from '../../../src/components-lib/gallery/folder-gallery-controller'; -import { THUMBNAIL_WIDTH_DEFAULT } from '../../../src/config/schema/common/controls/thumbnails'; -import { ViewFolder } from '../../../src/view/item'; -import { FolderViewQuery } from '../../../src/view/query'; -import { QueryResults } from '../../../src/view/query-results'; -import { createFolder, createView, TestViewMedia } from '../../test-utils'; - -// @vitest-environment jsdom -describe('FolderGalleryController', () => { - describe('should set thumbnail size', () => { - it('should set thumbnail size explicitly', () => { - const host = document.createElement('div'); - const controller = new FolderGalleryController(host); - - controller.setThumbnailSize(100); - - expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe( - '100px', - ); - }); - - it('should set thumbnail size implicitly', () => { - const host = document.createElement('div'); - const controller = new FolderGalleryController(host); - - controller.setThumbnailSize(); - - expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe( - `${THUMBNAIL_WIDTH_DEFAULT}px`, - ); - }); - }); - - describe('should get column width', () => { - it('should get default column width', () => { - const host = document.createElement('div'); - const controller = new FolderGalleryController(host); - - expect(controller.getColumnWidth()).toBe(THUMBNAIL_WIDTH_DEFAULT); - }); - - it('should get column width with defailts', () => { - const host = document.createElement('div'); - const controller = new FolderGalleryController(host); - - expect( - controller.getColumnWidth({ - size: 100, - show_details: true, - show_favorite_control: true, - show_timeline_control: true, - show_download_control: true, - }), - ).toBe(FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN); - }); - - it('should get column width with explicit size', () => { - const host = document.createElement('div'); - const controller = new FolderGalleryController(host); - - expect( - controller.getColumnWidth({ - size: 142, - show_details: false, - show_favorite_control: true, - show_timeline_control: true, - show_download_control: true, - }), - ).toBe(142); - }); - }); - - describe('should get column count round method', () => { - it('should get default column count round method', () => { - const host = document.createElement('div'); - const controller = new FolderGalleryController(host); - - expect(controller.getColumnCountRoundMethod()).toBe('ceil'); - }); - - it('should get column count round method when details being shown', () => { - const host = document.createElement('div'); - const controller = new FolderGalleryController(host); - - expect( - controller.getColumnCountRoundMethod({ - size: 100, - show_details: true, - show_favorite_control: true, - show_timeline_control: true, - show_download_control: true, - }), - ).toBe('floor'); - }); - }); - - describe('should handle item clicks', () => { - it('should ignore calls without view', () => { - const host = document.createElement('div'); - const controller = new FolderGalleryController(host); - - const viewManager = mock(); - - const item = new TestViewMedia(); - controller.itemClickHandler(viewManager, item, new Event('click')); - - expect(viewManager.setViewByParameters).not.toHaveBeenCalled(); - expect(viewManager.setViewByParametersWithExistingQuery).not.toHaveBeenCalled(); - }); - - it('should handle media item click', () => { - const host = document.createElement('div'); - const item_1 = new TestViewMedia(); - const item_2 = new TestViewMedia(); - - const controller = new FolderGalleryController(host); - const event = new Event('click'); - - const view = createView({ - queryResults: new QueryResults({ results: [item_1, item_2], selectedIndex: 0 }), - }); - - const viewManager = mock(); - viewManager.getView.mockReturnValue(view); - - controller.itemClickHandler(viewManager, item_2, event); - - expect(viewManager.setViewByParameters).toHaveBeenCalledWith({ - params: { - view: 'media', - queryResults: expect.any(QueryResults), - }, - }); - - const newQueryResults = - viewManager.setViewByParameters.mock.calls[0][0]?.params?.queryResults; - expect(newQueryResults).toBeInstanceOf(QueryResults); - expect(newQueryResults?.getSelectedResult()).toBe(item_2); - }); - - describe('should handle folder click', () => { - it('should handle normal folder click', () => { - const folder = createFolder(); - const folderItem = new ViewFolder(folder, { - id: 'parent', - }); - - const controller = new FolderGalleryController(document.createElement('div')); - const event = new Event('click'); - - const view = createView({ - queryResults: new QueryResults({ - results: [new TestViewMedia(), folderItem], - selectedIndex: 0, - }), - query: new FolderViewQuery({ - folder, - path: [{ ha: { id: 'grandparent' } }], - }), - }); - - const viewManager = mock(); - viewManager.getView.mockReturnValue(view); - - const foldersManager = mock(); - foldersManager.generateChildFolderQuery.mockReturnValue({ - folder, - path: [ - { ha: { id: 'grandparent' } }, - { folder: folderItem, ha: { id: 'parent' } }, - ], - }); - - controller.itemClickHandler(viewManager, folderItem, event, foldersManager); - - expect(viewManager.setViewByParametersWithExistingQuery).toHaveBeenCalledWith({ - params: { - query: expect.any(FolderViewQuery), - }, - }); - - const newQuery = - viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params - ?.query; - expect(newQuery).toBeInstanceOf(FolderViewQuery); - expect(newQuery?.getQuery()).toEqual({ - folder, - path: [ - { ha: { id: 'grandparent' } }, - { folder: folderItem, ha: { id: 'parent' } }, - ], - }); - }); - - it('should ignore folder click without folders manager', () => { - const folder = createFolder(); - const folderItem = new ViewFolder(folder, { - id: 'parent', - }); - - const controller = new FolderGalleryController(document.createElement('div')); - const event = new Event('click'); - - const view = createView({ - queryResults: new QueryResults({ - results: [new TestViewMedia(), folderItem], - selectedIndex: 0, - }), - query: new FolderViewQuery({ - folder, - path: [{ ha: { id: 'grandparent' } }], - }), - }); - - const viewManager = mock(); - viewManager.getView.mockReturnValue(view); - - controller.itemClickHandler(viewManager, folderItem, event); - - expect(viewManager.setViewByParametersWithExistingQuery).not.toHaveBeenCalled(); - }); - - it('should handle folder click without query', () => { - const controller = new FolderGalleryController(document.createElement('div')); - - const view = createView({ - query: null, - }); - const viewManager = mock(); - viewManager.getView.mockReturnValue(view); - - controller.itemClickHandler( - viewManager, - new ViewFolder(createFolder(), { - id: 'parent', - }), - new Event('click'), - ); - - expect(viewManager.setViewByParametersWithExistingQuery).not.toBeCalled(); - expect(viewManager.setViewByParameters).not.toBeCalled(); - }); - - it('should handle folder click without path', () => { - const controller = new FolderGalleryController(document.createElement('div')); - const folder = createFolder(); - - const view = createView({ - query: new FolderViewQuery({ - folder, - path: [{ ha: { id: 'id' } }], - }), - }); - const viewManager = mock(); - viewManager.getView.mockReturnValue(view); - - controller.itemClickHandler( - viewManager, - new ViewFolder(folder), - new Event('click'), - ); - - expect(viewManager.setViewByParametersWithExistingQuery).not.toBeCalled(); - expect(viewManager.setViewByParameters).not.toBeCalled(); - }); - }); - }); -}); diff --git a/tests/components-lib/gallery/media-gallery-controller.test.ts b/tests/components-lib/gallery/media-gallery-controller.test.ts deleted file mode 100644 index ec3c4c9b..00000000 --- a/tests/components-lib/gallery/media-gallery-controller.test.ts +++ /dev/null @@ -1,427 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import { ExtendedMediaQueryResult } from '../../../src/camera-manager/manager'; -import { - EventQuery, - QueryType, - RecordingQuery, -} from '../../../src/camera-manager/types'; -import { ViewManagerEpoch } from '../../../src/card-controller/view/types'; -import { ViewManager } from '../../../src/card-controller/view/view-manager'; -import { - MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN, - MediaGalleryController, -} from '../../../src/components-lib/gallery/media-gallery-controller'; -import { THUMBNAIL_WIDTH_DEFAULT } from '../../../src/config/schema/common/controls/thumbnails'; -import { - EventMediaQuery, - FolderViewQuery, - RecordingMediaQuery, -} from '../../../src/view/query'; -import { QueryResults } from '../../../src/view/query-results'; -import { - createCameraManager, - createLitElement, - createView, - TestViewMedia, -} from '../../test-utils'; - -// @vitest-environment jsdom -describe('MediaGalleryController', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('getMedia', () => { - it('should return null initially', () => { - expect(new MediaGalleryController(createLitElement()).getMedia()).toBeNull(); - }); - }); - - describe('setMediaFromView', () => { - it('should set media from view if media is null', () => { - const controller = new MediaGalleryController(createLitElement()); - controller.setMediaFromView(null); - expect(controller.getMedia()).toBeNull(); - }); - - it('should set media from view if results are null', () => { - const controller = new MediaGalleryController(createLitElement()); - controller.setMediaFromView(createView({ queryResults: new QueryResults() })); - expect(controller.getMedia()).toEqual([]); - }); - - it('should update media when query results first set', () => { - const media_1 = new TestViewMedia({ id: 'one' }); - const media_2 = new TestViewMedia({ id: 'two' }); - const newView = createView({ - queryResults: new QueryResults({ results: [media_1, media_2] }), - }); - const controller = new MediaGalleryController(createLitElement()); - controller.setMediaFromView(newView); - expect(controller.getMedia()).toEqual([media_2, media_1]); - }); - - it('should update media when query results change', () => { - const media_1 = new TestViewMedia({ id: 'one' }); - const media_2 = new TestViewMedia({ id: 'two' }); - const media_3 = new TestViewMedia({ id: 'three' }); - - const oldView = createView({ - queryResults: new QueryResults({ results: [media_1, media_2] }), - }); - const newView = createView({ - queryResults: new QueryResults({ results: [media_2, media_3] }), - }); - - const controller = new MediaGalleryController(createLitElement()); - controller.setMediaFromView(newView, oldView); - expect(controller.getMedia()).toEqual([media_3, media_2]); - }); - - it('should not update media if query results are the same', () => { - const media_1 = new TestViewMedia({ id: 'one' }); - const media_2 = new TestViewMedia({ id: 'two' }); - const results = [media_1, media_2]; - const oldView = createView({ - queryResults: new QueryResults({ results }), - }); - const newView = createView({ - queryResults: new QueryResults({ results }), - }); - - const controller = new MediaGalleryController(createLitElement()); - controller.setMediaFromView(oldView); - const media = controller.getMedia(); - - controller.setMediaFromView(newView, oldView); - - expect(controller.getMedia()).toBe(media); - }); - }); - - describe('should set thumbnail size', () => { - it('should set thumbnail size explicitly', () => { - const host = document.createElement('div'); - const controller = new MediaGalleryController(host); - - controller.setThumbnailSize(100); - - expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe( - '100px', - ); - }); - - it('should set thumbnail size implicitly', () => { - const host = document.createElement('div'); - const controller = new MediaGalleryController(host); - - controller.setThumbnailSize(); - - expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe( - `${THUMBNAIL_WIDTH_DEFAULT}px`, - ); - }); - }); - - describe('should get column width', () => { - it('should get default column width', () => { - const host = document.createElement('div'); - const controller = new MediaGalleryController(host); - - expect(controller.getColumnWidth()).toBe(THUMBNAIL_WIDTH_DEFAULT); - }); - - it('should get column width with defailts', () => { - const host = document.createElement('div'); - const controller = new MediaGalleryController(host); - - expect( - controller.getColumnWidth({ - size: 100, - show_details: true, - show_favorite_control: true, - show_timeline_control: true, - show_download_control: true, - }), - ).toBe(MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN); - }); - - it('should get column width with explicit size', () => { - const host = document.createElement('div'); - const controller = new MediaGalleryController(host); - - expect( - controller.getColumnWidth({ - size: 142, - show_details: false, - show_favorite_control: true, - show_timeline_control: true, - show_download_control: true, - }), - ).toBe(142); - }); - }); - - describe('should get column count round method', () => { - it('should get default column count round method', () => { - const host = document.createElement('div'); - const controller = new MediaGalleryController(host); - - expect(controller.getColumnCountRoundMethod()).toBe('ceil'); - }); - - it('should get column count round method when details being shown', () => { - const host = document.createElement('div'); - const controller = new MediaGalleryController(host); - - expect( - controller.getColumnCountRoundMethod({ - size: 100, - show_details: true, - show_favorite_control: true, - show_timeline_control: true, - show_download_control: true, - }), - ).toBe('floor'); - }); - }); - - describe('extendMediaGallery', () => { - it('should do nothing if no view is available', async () => { - const controller = new MediaGalleryController(createLitElement()); - const cameraManager = createCameraManager(); - const viewManagerEpoch = mock(); - viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(null); - - await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier'); - expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled(); - }); - - it('should do nothing if no queries are available', async () => { - const controller = new MediaGalleryController(createLitElement()); - const cameraManager = createCameraManager(); - const viewManagerEpoch = mock(); - viewManagerEpoch.manager.getView = vi.fn().mockReturnValue( - createView({ - query: new EventMediaQuery(), - queryResults: new QueryResults({ results: [new TestViewMedia()] }), - }), - ); - - await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier'); - expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled(); - }); - - it('should do nothing if no query results are available', async () => { - const controller = new MediaGalleryController(createLitElement()); - const cameraManager = createCameraManager(); - const viewManagerEpoch = mock(); - viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(createView()); - - await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier'); - expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled(); - }); - - it('should do nothing if non-media query is present', async () => { - const controller = new MediaGalleryController(createLitElement()); - const cameraManager = createCameraManager(); - const viewManagerEpoch = mock(); - viewManagerEpoch.manager.getView = vi - .fn() - .mockReturnValue(createView({ query: new FolderViewQuery() })); - - await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier'); - expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled(); - }); - - it('should successfully extend event media queries', async () => { - const controller = new MediaGalleryController(createLitElement()); - const cameraManager = createCameraManager(); - const viewManagerEpoch = mock({ manager: mock() }); - - const existingRawQueries: EventQuery[] = [ - { type: QueryType.Event, cameraIDs: new Set(['camera.office']) }, - ]; - const existingMedia = [new TestViewMedia()]; - const baseView = createView({ - query: new EventMediaQuery(existingRawQueries), - queryResults: new QueryResults({ results: existingMedia }), - }); - viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(baseView); - - const newQueries: EventQuery[] = [ - { type: QueryType.Event, cameraIDs: new Set(['camera.office']) }, - ]; - const newResults = [new TestViewMedia()]; - - const extension: ExtendedMediaQueryResult = { - queries: newQueries, - results: newResults, - }; - vi.mocked(cameraManager.extendMediaQueries).mockResolvedValue(extension); - - await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier'); - expect(cameraManager.extendMediaQueries).toBeCalledWith( - existingRawQueries, - existingMedia, - 'earlier', - { useCache: true }, - ); - expect(viewManagerEpoch.manager.setViewByParameters).toBeCalledWith({ - baseView, - params: { - query: expect.any(EventMediaQuery), - queryResults: expect.any(QueryResults), - }, - }); - - const callArguments = vi.mocked(viewManagerEpoch.manager.setViewByParameters).mock - .lastCall?.[0]; - - expect(callArguments?.params?.query?.getQuery()).toEqual(newQueries); - expect(callArguments?.params?.queryResults?.getResults()).toEqual(newResults); - }); - }); - - it('should successfully extend recording media queries', async () => { - const controller = new MediaGalleryController(createLitElement()); - const cameraManager = createCameraManager(); - const viewManagerEpoch = mock({ manager: mock() }); - - const existingRawQueries: RecordingQuery[] = [ - { type: QueryType.Recording, cameraIDs: new Set(['camera.office']) }, - ]; - const existingMedia = [new TestViewMedia()]; - const baseView = createView({ - query: new RecordingMediaQuery(existingRawQueries), - queryResults: new QueryResults({ results: existingMedia }), - }); - viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(baseView); - - const newQueries: RecordingQuery[] = [ - { type: QueryType.Recording, cameraIDs: new Set(['camera.office']) }, - ]; - const newResults = [new TestViewMedia()]; - - const extension: ExtendedMediaQueryResult = { - queries: newQueries, - results: newResults, - }; - vi.mocked(cameraManager.extendMediaQueries).mockResolvedValue(extension); - - await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier'); - expect(cameraManager.extendMediaQueries).toBeCalledWith( - existingRawQueries, - existingMedia, - 'earlier', - { useCache: true }, - ); - expect(viewManagerEpoch.manager.setViewByParameters).toBeCalledWith({ - baseView, - params: { - query: expect.any(RecordingMediaQuery), - queryResults: expect.any(QueryResults), - }, - }); - - const callArguments = vi.mocked(viewManagerEpoch.manager.setViewByParameters).mock - .lastCall?.[0]; - - expect(callArguments?.params?.query?.getQuery()).toEqual(newQueries); - expect(callArguments?.params?.queryResults?.getResults()).toEqual(newResults); - }); - - it('should handle errors gracefully', async () => { - const controller = new MediaGalleryController(createLitElement()); - const cameraManager = createCameraManager(); - const viewManagerEpoch = mock({ manager: mock() }); - - viewManagerEpoch.manager.getView = vi.fn().mockReturnValue( - createView({ - query: new EventMediaQuery([ - { type: QueryType.Event, cameraIDs: new Set(['camera.office']) }, - ]), - queryResults: new QueryResults({ results: [new TestViewMedia()] }), - }), - ); - - vi.mocked(cameraManager.extendMediaQueries).mockRejectedValue( - new Error('Test error'), - ); - - const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); - await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier'); - - expect(viewManagerEpoch.manager.setViewByParameters).not.toHaveBeenCalled(); - - expect(consoleSpy).toBeCalledWith('Test error'); - }); - - it('should handle null extension gracefully', async () => { - const controller = new MediaGalleryController(createLitElement()); - const cameraManager = createCameraManager(); - const viewManagerEpoch = mock({ manager: mock() }); - - viewManagerEpoch.manager.getView = vi.fn().mockReturnValue( - createView({ - query: new EventMediaQuery([ - { type: QueryType.Event, cameraIDs: new Set(['camera.office']) }, - ]), - queryResults: new QueryResults({ results: [new TestViewMedia()] }), - }), - ); - - vi.mocked(cameraManager.extendMediaQueries).mockResolvedValue(null); - - await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier'); - expect(viewManagerEpoch.manager.setViewByParameters).not.toHaveBeenCalled(); - }); - - describe('should handle item click', () => { - it('should do nothing without a view', () => { - const controller = new MediaGalleryController(createLitElement()); - const viewManager = mock(); - - controller.itemClickHandler(viewManager, 0, new Event('click')); - - expect(viewManager.setViewByParameters).not.toHaveBeenCalled(); - }); - - it('should change view to selected item', () => { - const media = [ - new TestViewMedia({ id: 'zero' }), - new TestViewMedia({ id: 'one' }), - ]; - const view = createView({ - queryResults: new QueryResults({ results: media, selectedIndex: 0 }), - }); - - const controller = new MediaGalleryController(createLitElement()); - controller.setMediaFromView(view); - - const viewManager = mock(); - viewManager.getView.mockReturnValue(view); - - controller.itemClickHandler( - viewManager, - // As the media in the gallery is reversed, passing in 0 as a - // reversedIndex argument is requesting the selection of the media item - // with index 1 (from an array of 2 media items). - 0, - new Event('click'), - ); - - expect(viewManager.setViewByParameters).toBeCalledWith({ - params: { - view: 'media', - queryResults: expect.any(QueryResults), - }, - }); - - const newQueryResults = vi.mocked(viewManager.setViewByParameters).mock - .lastCall?.[0]?.params?.queryResults; - expect(newQueryResults?.getSelectedResult()).toEqual(media[1]); - }); - }); -}); diff --git a/tests/components-lib/icon-controller.test.ts b/tests/components-lib/icon-controller.test.ts index 44d0bf76..5fb47018 100644 --- a/tests/components-lib/icon-controller.test.ts +++ b/tests/components-lib/icon-controller.test.ts @@ -22,6 +22,12 @@ describe('IconController', () => { ); }); + it('should return tplink SVG for tplink icon', () => { + expect(new IconController().getCustomIcon({ icon: 'tplink' })).toMatch( + /tplink.svg$/, + ); + }); + it('should return iris SVG for iris icon', () => { expect(new IconController().getCustomIcon({ icon: 'iris' })).toMatch(/iris.svg$/); }); diff --git a/tests/components-lib/media-filter-controller.test.ts b/tests/components-lib/media-filter-controller.test.ts index 4fc1578b..c7f424b6 100644 --- a/tests/components-lib/media-filter-controller.test.ts +++ b/tests/components-lib/media-filter-controller.test.ts @@ -1,31 +1,45 @@ import { endOfDay, startOfDay, sub } from 'date-fns'; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; +import { + afterAll, + assert, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { mock, MockProxy } from 'vitest-mock-extended'; import { Capabilities } from '../../src/camera-manager/capabilities'; -import { CameraManagerStore } from '../../src/camera-manager/store'; -import { QueryType } from '../../src/camera-manager/types'; +import { + EventQuery, + MediaQuery, + QueryType, + RecordingQuery, + ReviewQuery, +} from '../../src/camera-manager/types'; +import { FoldersManager } from '../../src/card-controller/folders/manager'; import { ViewManager } from '../../src/card-controller/view/view-manager'; import { MediaFilterController, MediaFilterCoreDefaults, MediaFilterCoreFavoriteSelection, + MediaFilterCoreReviewedSelection, MediaFilterCoreWhen, MediaFilterMediaType, } from '../../src/components-lib/media-filter-controller'; -import { EventMediaQuery, Query, RecordingMediaQuery } from '../../src/view/query'; +import { QuerySource } from '../../src/query-source'; +import { UnifiedQuery } from '../../src/view/unified-query'; import { createCameraConfig, createCameraManager, - createCapabilities, createLitElement, createPerformanceConfig, createStore, createView, } from '../test-utils'; -const createCameraStore = (options?: { - capabilities: Capabilities; -}): CameraManagerStore => { +const createCameraStore = (options?: { capabilities: Capabilities }) => { return createStore([ { cameraID: 'camera.kitchen', @@ -43,6 +57,268 @@ const createCameraStore = (options?: { ]); }; +const getQueryNodes = (viewManager: MockProxy, n = 0): MediaQuery[] => { + const query = + viewManager.setViewByParametersWithExistingQuery.mock.calls[n]?.[0]?.params?.query; + return query ? query.getMediaQueries() : []; +}; + +type TestQueryNode = ( + | Partial> + | Partial> + | Partial> +) & { + type: QueryType; + cameraID?: string; + cameraIDs?: Set; +}; + +const createQueryWithNodes = (nodes: TestQueryNode[]): UnifiedQuery => { + const query = new UnifiedQuery(); + for (const node of nodes) { + const { cameraID, cameraIDs: explicitCameraIDs, type, ...rest } = node; + const cameraIDs = explicitCameraIDs ?? new Set([cameraID ?? 'camera.kitchen']); + const mediaQuery: MediaQuery = { + source: QuerySource.Camera, + type, + cameraIDs, + ...rest, + }; + query.addNode(mediaQuery); + } + return query; +}; + +const queryDefaultTestCases: Array<[string, UnifiedQuery, MediaFilterCoreDefaults]> = [ + [ + 'same cameras', + createQueryWithNodes([ + { type: QueryType.Event, cameraID: 'camera.kitchen', hasClip: true }, + { type: QueryType.Event, cameraID: 'camera.kitchen', hasClip: true }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Clips], + }, + ], + [ + 'different cameras', + createQueryWithNodes([ + { type: QueryType.Event, cameraID: 'camera.kitchen', hasClip: true }, + { type: QueryType.Event, cameraID: 'camera.living_room', hasClip: true }, + ]), + { + // cameraIDs included because they differ from visible cameras + cameraIDs: ['camera.kitchen', 'camera.living_room'], + mediaTypes: [MediaFilterMediaType.Clips], + }, + ], + [ + 'all favorites', + createQueryWithNodes([ + { type: QueryType.Event, favorite: true, hasClip: true }, + { type: QueryType.Event, favorite: true, hasClip: true }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Clips], + favorite: MediaFilterCoreFavoriteSelection.Favorite, + }, + ], + [ + 'all not favorites', + createQueryWithNodes([ + { type: QueryType.Event, favorite: false, hasClip: true }, + { type: QueryType.Event, favorite: false, hasClip: true }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Clips], + favorite: MediaFilterCoreFavoriteSelection.NotFavorite, + }, + ], + [ + 'different favorites', + createQueryWithNodes([ + { type: QueryType.Event, favorite: true, hasClip: true }, + { type: QueryType.Event, favorite: false, hasClip: true }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Clips], + }, + ], + [ + 'same hasClip', + createQueryWithNodes([ + { type: QueryType.Event, hasClip: true }, + { type: QueryType.Event, hasClip: true }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Clips], + }, + ], + [ + 'different hasClip', + createQueryWithNodes([ + { type: QueryType.Event, hasClip: true }, + { type: QueryType.Event, hasClip: false }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Clips], + }, + ], + [ + 'same hasSnapshot', + createQueryWithNodes([ + { type: QueryType.Event, hasSnapshot: true }, + { type: QueryType.Event, hasSnapshot: true }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Snapshots], + }, + ], + [ + 'different hasSnapshot', + createQueryWithNodes([ + { type: QueryType.Event, hasSnapshot: true }, + { type: QueryType.Event, hasSnapshot: false }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Snapshots], + }, + ], + [ + 'same what', + createQueryWithNodes([ + { type: QueryType.Event, what: new Set(['person']), hasClip: true }, + { type: QueryType.Event, what: new Set(['person']), hasClip: true }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Clips], + what: ['person'], + }, + ], + [ + 'different what', + createQueryWithNodes([ + { type: QueryType.Event, what: new Set(['person']), hasClip: true }, + { type: QueryType.Event, what: new Set(['car']), hasClip: true }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Clips], + }, + ], + [ + 'same where', + createQueryWithNodes([ + { type: QueryType.Event, where: new Set(['front_door']), hasClip: true }, + { type: QueryType.Event, where: new Set(['front_door']), hasClip: true }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Clips], + where: ['front_door'], + }, + ], + [ + 'different where', + createQueryWithNodes([ + { type: QueryType.Event, where: new Set(['front_door']), hasClip: true }, + { type: QueryType.Event, where: new Set(['back_steps']), hasClip: true }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Clips], + }, + ], + [ + 'same tags', + createQueryWithNodes([ + { type: QueryType.Event, tags: new Set(['tag-1']), hasClip: true }, + { type: QueryType.Event, tags: new Set(['tag-1']), hasClip: true }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Clips], + tags: ['tag-1'], + }, + ], + [ + 'different tags', + createQueryWithNodes([ + { type: QueryType.Event, tags: new Set(['tag-1']), hasClip: true }, + { type: QueryType.Event, tags: new Set(['tag-2']), hasClip: true }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Clips], + }, + ], + [ + 'recordings', + createQueryWithNodes([{ type: QueryType.Recording }]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Recordings], + }, + ], + [ + 'reviews', + createQueryWithNodes([{ type: QueryType.Review }]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Reviews], + }, + ], + [ + 'reviewed true', + createQueryWithNodes([{ type: QueryType.Review, reviewed: true }]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Reviews], + reviewed: MediaFilterCoreReviewedSelection.Reviewed, + }, + ], + [ + 'reviewed false', + createQueryWithNodes([{ type: QueryType.Review, reviewed: false }]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Reviews], + reviewed: MediaFilterCoreReviewedSelection.NotReviewed, + }, + ], + [ + 'mixed favorites', + createQueryWithNodes([ + { type: QueryType.Event, favorite: true, hasClip: true }, + { type: QueryType.Event, favorite: false, hasClip: true }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Clips], + }, + ], + [ + 'mixed reviewed', + createQueryWithNodes([ + { type: QueryType.Review, reviewed: true }, + { type: QueryType.Review, reviewed: false }, + ]), + { + cameraIDs: ['camera.kitchen'], + mediaTypes: [MediaFilterMediaType.Reviews], + }, + ], +]; + // @vitest-environment jsdom describe('MediaFilterController', () => { beforeEach(() => { @@ -53,58 +329,37 @@ describe('MediaFilterController', () => { it('media type', () => { const controller = new MediaFilterController(createLitElement()); expect(controller.getMediaTypeOptions()).toEqual([ - { - value: MediaFilterMediaType.Clips, - label: 'Clips', - }, - { - value: MediaFilterMediaType.Snapshots, - label: 'Snapshots', - }, - { - value: MediaFilterMediaType.Recordings, - label: 'Recordings', - }, + { value: MediaFilterMediaType.Clips, label: 'Clips' }, + { value: MediaFilterMediaType.Snapshots, label: 'Snapshots' }, + { value: MediaFilterMediaType.Recordings, label: 'Recordings' }, + { value: MediaFilterMediaType.Reviews, label: 'Reviews' }, ]); }); it('favorite', () => { const controller = new MediaFilterController(createLitElement()); expect(controller.getFavoriteOptions()).toEqual([ - { - value: MediaFilterCoreFavoriteSelection.Favorite, - label: 'Favorite', - }, - { - value: MediaFilterCoreFavoriteSelection.NotFavorite, - label: 'Not Favorite', - }, + { value: MediaFilterCoreFavoriteSelection.Favorite, label: 'Favorite' }, + { value: MediaFilterCoreFavoriteSelection.NotFavorite, label: 'Not Favorite' }, + ]); + }); + + it('reviewed', () => { + const controller = new MediaFilterController(createLitElement()); + expect(controller.getReviewedOptions()).toEqual([ + { value: MediaFilterCoreReviewedSelection.Reviewed, label: 'Reviewed' }, + { value: MediaFilterCoreReviewedSelection.NotReviewed, label: 'Not Reviewed' }, ]); }); it('when', () => { const controller = new MediaFilterController(createLitElement()); expect(controller.getWhenOptions()).toEqual([ - { - value: MediaFilterCoreWhen.Today, - label: 'Today', - }, - { - value: MediaFilterCoreWhen.Yesterday, - label: 'Yesterday', - }, - { - value: MediaFilterCoreWhen.PastWeek, - label: 'Past Week', - }, - { - value: MediaFilterCoreWhen.PastMonth, - label: 'Past Month', - }, - { - value: MediaFilterCoreWhen.Custom, - label: 'Custom', - }, + { value: MediaFilterCoreWhen.Today, label: 'Today' }, + { value: MediaFilterCoreWhen.Yesterday, label: 'Yesterday' }, + { value: MediaFilterCoreWhen.PastWeek, label: 'Past Week' }, + { value: MediaFilterCoreWhen.PastMonth, label: 'Past Month' }, + { value: MediaFilterCoreWhen.Custom, label: 'Custom' }, ]); }); @@ -140,12 +395,9 @@ describe('MediaFilterController', () => { vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); const controller = new MediaFilterController(createLitElement()); - controller.computeCameraOptions(cameraManager); + controller.computeCameraOptions(cameraManager, mock()); expect(controller.getCameraOptions()).toEqual([ - { - label: 'Kitchen Camera', - value: 'camera.kitchen', - }, + { label: 'Kitchen Camera', value: 'camera.kitchen' }, ]); }); @@ -166,7 +418,7 @@ describe('MediaFilterController', () => { ); const controller = new MediaFilterController(createLitElement()); - controller.computeCameraOptions(cameraManager); + controller.computeCameraOptions(cameraManager, mock()); expect(controller.getCameraOptions()).toEqual([]); }); @@ -176,12 +428,9 @@ describe('MediaFilterController', () => { vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); const controller = new MediaFilterController(createLitElement()); - controller.computeCameraOptions(cameraManager); + controller.computeCameraOptions(cameraManager, mock()); expect(controller.getCameraOptions()).toEqual([ - { - label: 'camera.kitchen', - value: 'camera.kitchen', - }, + { label: 'camera.kitchen', value: 'camera.kitchen' }, ]); }); }); @@ -209,14 +458,8 @@ describe('MediaFilterController', () => { const controller = new MediaFilterController(host); await controller.computeMetadataOptions(cameraManager); expect(controller.getWhatOptions()).toEqual([ - { - value: 'car', - label: 'Car', - }, - { - value: 'person', - label: 'Person', - }, + { value: 'car', label: 'Car' }, + { value: 'person', label: 'Person' }, ]); expect(host.requestUpdate).toBeCalled(); }); @@ -231,14 +474,8 @@ describe('MediaFilterController', () => { const controller = new MediaFilterController(host); await controller.computeMetadataOptions(cameraManager); expect(controller.getWhereOptions()).toEqual([ - { - value: 'back_yard', - label: 'Back Yard', - }, - { - value: 'front_door', - label: 'Front Door', - }, + { value: 'back_yard', label: 'Back Yard' }, + { value: 'front_door', label: 'Front Door' }, ]); expect(host.requestUpdate).toBeCalled(); }); @@ -253,14 +490,8 @@ describe('MediaFilterController', () => { const controller = new MediaFilterController(host); await controller.computeMetadataOptions(cameraManager); expect(controller.getTagsOptions()).toEqual([ - { - value: 'tag-1', - label: 'Tag-1', - }, - { - value: 'tag-2', - label: 'Tag-2', - }, + { value: 'tag-1', label: 'Tag-1' }, + { value: 'tag-2', label: 'Tag-2' }, ]); expect(host.requestUpdate).toBeCalled(); }); @@ -288,85 +519,6 @@ describe('MediaFilterController', () => { }); }); - describe('should get correct controls to show', () => { - it('view with events', () => { - const viewManager = mock(); - viewManager.getView.mockReturnValue(createView({ query: new EventMediaQuery() })); - const cameraManager = createCameraManager(); - - const controller = new MediaFilterController(createLitElement()); - controller.setViewManager(viewManager); - expect(controller.getControlsToShow(cameraManager)).toMatchObject({ - events: true, - recordings: false, - }); - }); - - it('view with recordings', () => { - const viewManager = mock(); - viewManager.getView.mockReturnValue( - createView({ query: new RecordingMediaQuery() }), - ); - const cameraManager = createCameraManager(); - - const controller = new MediaFilterController(createLitElement()); - controller.setViewManager(viewManager); - expect(controller.getControlsToShow(cameraManager)).toMatchObject({ - events: false, - recordings: true, - }); - }); - - it('can favorite events', () => { - const viewManager = mock(); - viewManager.getView.mockReturnValue(createView({ query: new EventMediaQuery() })); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( - createCapabilities({ 'favorite-events': true }), - ); - - const controller = new MediaFilterController(createLitElement()); - controller.setViewManager(viewManager); - - expect(controller.getControlsToShow(cameraManager)).toMatchObject({ - favorites: true, - }); - }); - - it('can favorite recordings', () => { - const viewManager = mock(); - viewManager.getView.mockReturnValue( - createView({ query: new RecordingMediaQuery() }), - ); - - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( - createCapabilities({ 'favorite-recordings': true }), - ); - - const controller = new MediaFilterController(createLitElement()); - controller.setViewManager(viewManager); - - expect(controller.getControlsToShow(cameraManager)).toMatchObject({ - favorites: true, - }); - }); - - it('can not favorite without a query', () => { - const viewManager = mock(); - viewManager.getView.mockReturnValue(createView()); - - const cameraManager = createCameraManager(); - - const controller = new MediaFilterController(createLitElement()); - controller.setViewManager(viewManager); - - expect(controller.getControlsToShow(cameraManager)).toMatchObject({ - favorites: false, - }); - }); - }); - describe('should handle value change', () => { it('must have visible cameras', async () => { const viewManager = mock(); @@ -374,15 +526,20 @@ describe('MediaFilterController', () => { const controller = new MediaFilterController(createLitElement()); controller.setViewManager(viewManager); - await controller.valueChangeHandler(createCameraManager(), {}, { when: {} }); + await controller.valueChangeHandler( + createCameraManager(), + mock(), + {}, + { when: {} }, + ); expect(viewManager.setViewByParametersWithExistingQuery).not.toBeCalled(); }); describe('with events media type', () => { - it.each([['clips' as const], ['snapshots' as const]])( + it.each([MediaFilterMediaType.Clips, MediaFilterMediaType.Snapshots])( '%s', - async (viewName: 'clips' | 'snapshots') => { + async (mediaType: MediaFilterMediaType) => { const host = createLitElement(); const viewManager = mock(); viewManager.getView.mockReturnValue(createView()); @@ -397,23 +554,16 @@ describe('MediaFilterController', () => { await controller.valueChangeHandler( cameraManager, + mock(), { performance: createPerformanceConfig({ - features: { - media_chunk_size: 11, - }, + features: { media_chunk_size: 11 }, }), }, { - camera: 'camera.kitchen', - mediaType: - viewName === 'clips' - ? MediaFilterMediaType.Clips - : MediaFilterMediaType.Snapshots, - when: { - to: to, - from: from, - }, + camera: ['camera.kitchen'], + mediaTypes: [mediaType], + when: { to, from }, tags: ['tag-1', 'tag-2'], what: ['what-1', 'what-2'], where: ['where-1', 'where-2'], @@ -424,26 +574,24 @@ describe('MediaFilterController', () => { expect(viewManager.setViewByParametersWithExistingQuery).toBeCalledWith({ params: expect.objectContaining({ camera: 'camera.kitchen', - view: viewName, }), }); - expect( - viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQuery(), - ).toEqual([ - { - cameraIDs: new Set(['camera.kitchen']), - ...(viewName === 'clips' && { hasClip: true }), - ...(viewName === 'snapshots' && { hasSnapshot: true }), - type: 'event-query', - tags: new Set(['tag-1', 'tag-2']), - what: new Set(['what-1', 'what-2']), - where: new Set(['where-1', 'where-2']), - favorite: true, - start: from, - end: to, - limit: 11, - }, - ]); + + const nodes = getQueryNodes(viewManager); + expect(nodes).toHaveLength(1); + expect(nodes?.[0]).toMatchObject({ + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + ...(mediaType === MediaFilterMediaType.Clips && { hasClip: true }), + ...(mediaType === MediaFilterMediaType.Snapshots && { hasSnapshot: true }), + tags: new Set(['tag-1', 'tag-2']), + what: new Set(['what-1', 'what-2']), + where: new Set(['where-1', 'where-2']), + favorite: true, + start: from, + end: to, + limit: 11, + }); expect(host.requestUpdate).toBeCalled(); }, @@ -452,14 +600,10 @@ describe('MediaFilterController', () => { it('with recordings media type', async () => { const host = createLitElement(); - const cameraManager = createCameraManager(createCameraStore()); - const viewManager = mock(); viewManager.getView.mockReturnValue(createView()); - vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); - const controller = new MediaFilterController(host); controller.setViewManager(viewManager); @@ -468,19 +612,15 @@ describe('MediaFilterController', () => { await controller.valueChangeHandler( cameraManager, + mock(), { performance: createPerformanceConfig({ - features: { - media_chunk_size: 11, - }, + features: { media_chunk_size: 11 }, }), }, { - mediaType: MediaFilterMediaType.Recordings, - when: { - to: to, - from: from, - }, + mediaTypes: [MediaFilterMediaType.Recordings], + when: { to, from }, favorite: MediaFilterCoreFavoriteSelection.Favorite, }, ); @@ -488,30 +628,81 @@ describe('MediaFilterController', () => { expect(viewManager.setViewByParametersWithExistingQuery).toBeCalledWith({ params: expect.objectContaining({ camera: 'camera.kitchen', - view: 'recordings', }), }); - expect( - viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQuery(), - ).toEqual([ - { - cameraIDs: new Set(['camera.kitchen']), - type: 'recording-query', - favorite: true, - start: from, - end: to, - limit: 11, - }, - ]); + const nodes = getQueryNodes(viewManager); + expect(nodes).toHaveLength(1); + expect(nodes?.[0]).toMatchObject({ + type: QueryType.Recording, + cameraIDs: new Set(['camera.kitchen']), + favorite: true, + start: from, + end: to, + limit: 11, + }); expect(host.requestUpdate).toBeCalled(); }); - it('without favorites', async () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); + it('with reviews media type', async () => { + const host = createLitElement(); + const cameraManager = createCameraManager(createCameraStore()); + const viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); + const controller = new MediaFilterController(host); + controller.setViewManager(viewManager); + + await controller.valueChangeHandler( + cameraManager, + mock(), + {}, + { + mediaTypes: [MediaFilterMediaType.Reviews], + when: {}, + reviewed: MediaFilterCoreReviewedSelection.NotReviewed, + }, + ); + + expect(viewManager.setViewByParametersWithExistingQuery).toBeCalled(); + + const nodes = getQueryNodes(viewManager); + expect(nodes).toHaveLength(1); + expect(nodes?.[0]).toMatchObject({ + type: QueryType.Review, + reviewed: false, + }); + }); + + it('with multiple media types', async () => { + const host = createLitElement(); + const cameraManager = createCameraManager(createCameraStore()); + const viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); + + const controller = new MediaFilterController(host); + controller.setViewManager(viewManager); + + await controller.valueChangeHandler( + cameraManager, + mock(), + {}, + { + mediaTypes: [MediaFilterMediaType.Clips, MediaFilterMediaType.Recordings], + when: {}, + }, + ); + + expect(viewManager.setViewByParametersWithExistingQuery).toBeCalled(); + + const nodes = getQueryNodes(viewManager); + expect(nodes).toHaveLength(2); + expect(nodes?.map((s) => s.type)).toEqual([QueryType.Event, QueryType.Recording]); + }); + + it('all favorites', async () => { + const cameraManager = createCameraManager(createCameraStore()); const viewManager = mock(); viewManager.getView.mockReturnValue(createView()); @@ -520,9 +711,44 @@ describe('MediaFilterController', () => { await controller.valueChangeHandler( cameraManager, + mock(), {}, { - mediaType: MediaFilterMediaType.Recordings, + mediaTypes: [MediaFilterMediaType.Clips], + when: {}, + favorite: MediaFilterCoreFavoriteSelection.Favorite, + }, + ); + + expect(viewManager.setViewByParametersWithExistingQuery).toBeCalledWith({ + params: expect.objectContaining({ + camera: 'camera.kitchen', + }), + }); + + const nodes = getQueryNodes(viewManager); + expect(nodes).toHaveLength(1); + expect(nodes?.[0]).toMatchObject({ + type: QueryType.Event, + cameraIDs: new Set(['camera.kitchen']), + favorite: true, + }); + }); + + it('without favorites', async () => { + const cameraManager = createCameraManager(createCameraStore()); + const viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); + + const controller = new MediaFilterController(createLitElement()); + controller.setViewManager(viewManager); + + await controller.valueChangeHandler( + cameraManager, + mock(), + {}, + { + mediaTypes: [MediaFilterMediaType.Recordings], when: {}, }, ); @@ -530,20 +756,106 @@ describe('MediaFilterController', () => { expect(viewManager.setViewByParametersWithExistingQuery).toBeCalledWith({ params: expect.objectContaining({ camera: 'camera.kitchen', - view: 'recordings', }), }); - expect( - viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQuery(), - ).toEqual([ + const nodes = getQueryNodes(viewManager); + expect(nodes).toHaveLength(1); + expect(nodes?.[0]).toMatchObject({ + type: QueryType.Recording, + cameraIDs: new Set(['camera.kitchen']), + }); + }); + + it('with no media types selects all types', async () => { + const cameraManager = createCameraManager(createCameraStore()); + const viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); + + const controller = new MediaFilterController(createLitElement()); + controller.setViewManager(viewManager); + + await controller.valueChangeHandler( + cameraManager, + mock(), + {}, { - cameraIDs: new Set(['camera.kitchen']), - type: 'recording-query', + mediaTypes: [], + when: {}, }, + ); + + expect(viewManager.setViewByParametersWithExistingQuery).toBeCalled(); + + const nodes = getQueryNodes(viewManager); + // All 4 types selected for the single camera + expect(nodes).toHaveLength(4); + expect(nodes?.map((s) => s.type).sort()).toEqual([ + QueryType.Event, + QueryType.Event, + QueryType.Recording, + QueryType.Review, ]); }); + it('with multiple camera selections', async () => { + const cameraManager = createCameraManager(createCameraStore()); + const viewManager = mock(); + viewManager.getView.mockReturnValue(createView()); + + const controller = new MediaFilterController(createLitElement()); + controller.setViewManager(viewManager); + + await controller.valueChangeHandler( + cameraManager, + mock(), + {}, + { + camera: ['camera.kitchen', 'camera.living_room'], + mediaTypes: [MediaFilterMediaType.Clips], + when: {}, + }, + ); + + expect(viewManager.setViewByParametersWithExistingQuery).toBeCalledWith({ + params: { + query: expect.any(UnifiedQuery), + }, + }); + const mockCall = vi.mocked(viewManager.setViewByParametersWithExistingQuery).mock + .calls[0]; + assert(mockCall && mockCall[0]); + + const callParams = mockCall[0].params; + assert(callParams); + expect(callParams.camera).toBeUndefined(); + }); + + it('computeInitialDefaultsFromView with no cameras in query', () => { + const cameraManager = createCameraManager(createCameraStore()); + const foldersManager = mock(); + const viewManager = mock(); + + const query = new UnifiedQuery(); + const node: EventQuery = { + source: QuerySource.Camera, + type: QueryType.Event, + cameraIDs: new Set(), // Empty camera IDs + hasClip: true, + }; + query.addNode(node); + + const view = createView({ query }); + viewManager.getView.mockReturnValue(view); + + const controller = new MediaFilterController(createLitElement()); + controller.setViewManager(viewManager); + + controller.computeInitialDefaultsFromView(cameraManager, foldersManager); + + expect(controller.getDefaults()?.cameraIDs).toBeUndefined(); + }); + describe('with fixed when selection', () => { const date = new Date('2024-10-01T17:14'); @@ -553,7 +865,7 @@ describe('MediaFilterController', () => { }); afterAll(() => { - vi.useFakeTimers(); + vi.useRealTimers(); }); it.each([ @@ -579,9 +891,7 @@ describe('MediaFilterController', () => { new Date('2024-02-29T23:59:59.999'), ], ])('%s', async (value: MediaFilterCoreWhen | string, from: Date, to: Date) => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); - + const cameraManager = createCameraManager(createCameraStore()); const viewManager = mock(); viewManager.getView.mockReturnValue(createView()); @@ -590,30 +900,25 @@ describe('MediaFilterController', () => { await controller.valueChangeHandler( cameraManager, + mock(), {}, { - mediaType: MediaFilterMediaType.Recordings, - when: { - selected: value, - }, + mediaTypes: [MediaFilterMediaType.Recordings], + when: { selected: value }, }, ); - expect( - viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQuery(), - ).toEqual([ - { - cameraIDs: new Set(['camera.kitchen']), - type: 'recording-query', - start: from, - end: to, - }, - ]); + + const nodes = getQueryNodes(viewManager); + expect(nodes?.[0]).toMatchObject({ + type: QueryType.Recording, + cameraIDs: new Set(['camera.kitchen']), + start: from, + end: to, + }); }); it('custom without values', async () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); - + const cameraManager = createCameraManager(createCameraStore()); const viewManager = mock(); viewManager.getView.mockReturnValue(createView()); @@ -622,58 +927,48 @@ describe('MediaFilterController', () => { await controller.valueChangeHandler( cameraManager, + mock(), {}, { - mediaType: MediaFilterMediaType.Recordings, - when: { - selected: MediaFilterCoreWhen.Custom, - }, + mediaTypes: [MediaFilterMediaType.Recordings], + when: { selected: MediaFilterCoreWhen.Custom }, }, ); - expect( - viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQuery(), - ).toEqual([ - { - cameraIDs: new Set(['camera.kitchen']), - type: 'recording-query', - }, - ]); + const nodes = getQueryNodes(viewManager); + expect(nodes?.[0]).toMatchObject({ + type: QueryType.Recording, + cameraIDs: new Set(['camera.kitchen']), + }); + expect(nodes?.[0].start).toBeUndefined(); + expect(nodes?.[0].end).toBeUndefined(); }); }); }); describe('should calculate correct defaults', () => { it('with no query', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); - + const cameraManager = createCameraManager(createCameraStore()); const viewManager = mock(); viewManager.getView.mockReturnValue(createView()); const controller = new MediaFilterController(createLitElement()); controller.setViewManager(viewManager); - controller.computeInitialDefaultsFromView(cameraManager); + controller.computeInitialDefaultsFromView(cameraManager, mock()); expect(controller.getDefaults()).toBeNull(); }); - it('with no raw queries', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); - + it('with empty query', () => { + const cameraManager = createCameraManager(createCameraStore()); const viewManager = mock(); - viewManager.getView.mockReturnValue( - createView({ - query: new EventMediaQuery(), - }), - ); + viewManager.getView.mockReturnValue(createView({ query: new UnifiedQuery() })); const controller = new MediaFilterController(createLitElement()); controller.setViewManager(viewManager); - controller.computeInitialDefaultsFromView(cameraManager); + controller.computeInitialDefaultsFromView(cameraManager, mock()); expect(controller.getDefaults()).toBeNull(); }); @@ -685,315 +980,36 @@ describe('MediaFilterController', () => { const controller = new MediaFilterController(createLitElement()); controller.setViewManager(viewManager); - controller.computeInitialDefaultsFromView(createCameraManager()); + controller.computeInitialDefaultsFromView( + createCameraManager(), + mock(), + ); expect(controller.getDefaults()).toBeNull(); }); describe('for queries', () => { - it.each([ - [ - 'same cameras' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen', 'camera.living_room']), - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen', 'camera.living_room']), - }, - ]), - { - cameraIDs: ['camera.kitchen', 'camera.living_room'], - }, - ], - [ - 'different cameras' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.living_room']), - }, - ]), - {}, - ], - [ - 'all cameras' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - }, - ]), - {}, - ], - [ - 'different favorites ' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - favorite: true, - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - favorite: undefined, - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - favorite: false, - }, - ]), - {}, - ], - [ - 'all favorites' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - favorite: true, - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - favorite: true, - }, - ]), - { - favorite: MediaFilterCoreFavoriteSelection.Favorite, - }, - ], - [ - 'all not favorites' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - favorite: false, - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - favorite: false, - }, - ]), - { - favorite: MediaFilterCoreFavoriteSelection.NotFavorite, - }, - ], - [ - 'same hasClip' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - hasClip: true, - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - hasClip: true, - }, - ]), - { - mediaType: MediaFilterMediaType.Clips, - }, - ], - [ - 'different hasClip' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - hasClip: true, - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - hasClip: false, - }, - ]), - {}, - ], - [ - 'same hasSnapshot' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - hasSnapshot: true, - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - hasSnapshot: true, - }, - ]), - { - mediaType: MediaFilterMediaType.Snapshots, - }, - ], - [ - 'different hasSnapshot' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - hasSnapshot: true, - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - hasSnapshot: false, - }, - ]), - {}, - ], - [ - 'same what' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - what: new Set(['person']), - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - what: new Set(['person']), - }, - ]), - { - what: ['person' as const], - }, - ], - [ - 'different what' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - what: new Set(['person']), - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - what: new Set(['car']), - }, - ]), - {}, - ], - [ - 'same where' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - where: new Set(['front_door']), - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - where: new Set(['front_door']), - }, - ]), - { - where: ['front_door' as const], - }, - ], - [ - 'different where' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - where: new Set(['front_door']), - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - where: new Set(['back_steps']), - }, - ]), - {}, - ], - [ - 'same tags' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - tags: new Set(['tag-1']), - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - tags: new Set(['tag-1']), - }, - ]), - { - tags: ['tag-1' as const], - }, - ], - [ - 'different tags' as const, - new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - tags: new Set(['tag-1']), - }, - { - type: QueryType.Event, - cameraIDs: new Set(['camera.kitchen']), - tags: new Set(['tag-2']), - }, - ]), - {}, - ], - [ - 'recordings' as const, - new RecordingMediaQuery([ - { - type: QueryType.Recording, - cameraIDs: new Set(['camera.kitchen']), - }, - ]), - { - mediaType: MediaFilterMediaType.Recordings, - }, - ], - ])( + it.each(queryDefaultTestCases)( '%s', - ( - _name: string, - mediaQueries: Query, - defaults: MediaFilterCoreDefaults | null, - ) => { + (_name: string, query: UnifiedQuery, expected: MediaFilterCoreDefaults) => { + const cameraManager = createCameraManager(createCameraStore()); const viewManager = mock(); viewManager.getView.mockReturnValue( createView({ - query: mediaQueries, + camera: 'camera.kitchen', + query: query, }), ); const controller = new MediaFilterController(createLitElement()); controller.setViewManager(viewManager); - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); + controller.computeInitialDefaultsFromView( + cameraManager, + mock(), + ); - controller.computeInitialDefaultsFromView(cameraManager); - - expect(controller.getDefaults()).toEqual(defaults); + expect(controller.getDefaults()).toEqual(expected); }, ); }); diff --git a/tests/components-lib/media/details-controller.test.ts b/tests/components-lib/media/details-controller.test.ts new file mode 100644 index 00000000..e94d538e --- /dev/null +++ b/tests/components-lib/media/details-controller.test.ts @@ -0,0 +1,501 @@ +import { format } from 'date-fns'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CameraManager } from '../../../src/camera-manager/manager'; +import { ViewItemManager } from '../../../src/card-controller/view/item-manager'; +import { ViewManagerEpoch } from '../../../src/card-controller/view/types'; +import { + MediaDetailsController, + OverlayControlsContext, +} from '../../../src/components-lib/media/details-controller'; +import { OverlayMessageControl } from '../../../src/types'; +import { formatDateAndTime } from '../../../src/utils/basic'; +import { ViewFolder, ViewMediaType } from '../../../src/view/item'; +import { createCardAPI, createFolder, TestViewMedia } from '../../test-utils'; + +describe('MediaDetailsController', () => { + describe('should set heading', () => { + it('should set heading on event with what, tags and score', () => { + const item = new TestViewMedia({ + what: ['person', 'car'], + tags: ['tag1', 'tag2'], + score: 0.5, + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + expect(controller.getHeading()?.title).toBe('Person, Car: Tag1, Tag2 50.00%'); + }); + + it('should set heading on event with tags', () => { + const item = new TestViewMedia({ + tags: ['tag1', 'tag2'], + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + expect(controller.getHeading()?.title).toBe('Tag1, Tag2'); + }); + + it('should set heading on event with what', () => { + const item = new TestViewMedia({ + what: ['person', 'car'], + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + expect(controller.getHeading()?.title).toBe('Person, Car'); + }); + + it('should set null heading on event with no other information', () => { + const item = new TestViewMedia({ + mediaType: ViewMediaType.Snapshot, + what: null, + tags: null, + score: null, + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + expect(controller.getHeading()).toBeNull(); + }); + + it('should set heading on recording with camera metadata', () => { + const cameraManager = mock(); + cameraManager.getCameraMetadata.mockReturnValue({ + title: 'Camera Title', + icon: { icon: 'mdi:cow' }, + }); + + const item = new TestViewMedia({ + mediaType: ViewMediaType.Recording, + }); + + const controller = new MediaDetailsController(); + controller.calculate(cameraManager, item); + expect(controller.getHeading()?.title).toBe('Camera Title'); + }); + + it('should set heading on recording without camera metadata', () => { + const item = new TestViewMedia({ + mediaType: ViewMediaType.Recording, + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + expect(controller.getHeading()).toBeNull(); + }); + + it('should set no heading on folder', () => { + const item = new ViewFolder(createFolder(), []); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + expect(controller.getHeading()).toBeNull(); + }); + }); + + describe('should set details', () => { + describe('should have title in details', () => { + it('should have icon with title when there are other details', () => { + const item = new TestViewMedia({ + title: 'Test Event', + where: ['where1', 'where2'], + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + expect(controller.getDetails()).toContainEqual({ + title: 'Test Event', + icon: { icon: 'mdi:rename' }, + hint: 'Title', + }); + }); + + it('should not have icon with title when there are no other details', () => { + const item = new TestViewMedia({ + title: 'Test Event', + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + expect(controller.getDetails()).toEqual([ + { + title: 'Test Event', + }, + ]); + }); + + it('should not have title with a start time', () => { + const item = new TestViewMedia({ + title: 'Test Event', + startTime: new Date('2025-05-22T21:12:00Z'), + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + expect(controller.getDetails()).not.toContainEqual( + expect.objectContaining({ + title: 'Test Event', + }), + ); + }); + }); + + it('should have start time in details', () => { + const startTime = new Date('2025-05-18T17:03:00Z'); + const item = new TestViewMedia({ + startTime, + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + + // Use formatDateAndTime to generate expected value (formats in local time with seconds) + expect(controller.getDetails()).toContainEqual({ + title: formatDateAndTime(startTime, true), + hint: 'Start', + icon: { icon: 'mdi:calendar-clock-outline' }, + }); + }); + + describe('should have duration in details', () => { + it('should have duration in details', () => { + const item = new TestViewMedia({ + startTime: new Date('2025-05-18T17:03:00Z'), + endTime: new Date('2025-05-18T17:04:00Z'), + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + expect(controller.getDetails()).toContainEqual({ + title: '1m 0s', + hint: 'Duration', + icon: { icon: 'mdi:clock-outline' }, + }); + }); + + it('should have in-progress in details', () => { + const item = new TestViewMedia({ + startTime: new Date('2025-05-18T17:03:00Z'), + endTime: null, + inProgress: true, + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + expect(controller.getDetails()).toContainEqual({ + title: 'In Progress', + hint: 'Duration', + icon: { icon: 'mdi:clock-outline' }, + }); + }); + + it('should have duration and in-progress in details', () => { + const item = new TestViewMedia({ + startTime: new Date('2025-05-18T17:03:00Z'), + endTime: new Date('2025-05-18T17:04:00Z'), + inProgress: true, + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + expect(controller.getDetails()).toContainEqual({ + title: '1m 0s In Progress', + hint: 'Duration', + icon: { icon: 'mdi:clock-outline' }, + }); + }); + }); + + it('should have camera title in details', () => { + const cameraManager = mock(); + cameraManager.getCameraMetadata.mockReturnValue({ + title: 'Camera Title', + icon: { icon: 'mdi:cow' }, + }); + + const item = new TestViewMedia({ + cameraID: 'camera_1', + }); + + const controller = new MediaDetailsController(); + controller.calculate(cameraManager, item); + expect(controller.getDetails()).toContainEqual({ + title: 'Camera Title', + hint: 'Camera', + icon: { icon: 'mdi:cctv' }, + }); + }); + + it('should have where in details', () => { + const item = new TestViewMedia({ + cameraID: 'camera_1', + where: ['where1', 'where2'], + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + expect(controller.getDetails()).toContainEqual({ + title: 'Where1, Where2', + hint: 'Where', + icon: { icon: 'mdi:map-marker-outline' }, + }); + }); + + it('should have tags in details', () => { + const item = new TestViewMedia({ + cameraID: 'camera_1', + tags: ['tag1', 'tag2'], + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + expect(controller.getDetails()).toContainEqual({ + title: 'Tag1, Tag2', + hint: 'Tag', + icon: { icon: 'mdi:tag' }, + }); + }); + + it('should have seek in details', () => { + const item = new TestViewMedia(); + const seekTime = new Date('2025-05-20T07:14:57Z'); + + const controller = new MediaDetailsController(); + controller.calculate(null, item, seekTime); + + // Use format() to generate expected value (formats in local time) + expect(controller.getDetails()).toContainEqual({ + title: format(seekTime, 'HH:mm:ss'), + hint: 'Seek', + icon: { icon: 'mdi:clock-fast' }, + }); + }); + it('should set heading on review', () => { + const item = new TestViewMedia({ + mediaType: ViewMediaType.Review, + title: 'Review Title', + severity: 'high', + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + const heading = controller.getHeading(); + expect(heading?.title).toBe('Review Title'); + expect(heading?.emphasis).toBe('high'); + expect(heading?.icon).toEqual({ icon: 'mdi:circle-medium' }); + expect(heading?.hint).toBe('Severity: High'); + }); + + it('should set heading on review without severity', () => { + const item = new TestViewMedia({ + mediaType: ViewMediaType.Review, + title: 'Review Title', + severity: null, + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + const heading = controller.getHeading(); + expect(heading?.title).toBe('Review Title'); + expect(heading?.emphasis).toBeUndefined(); + }); + + it('should set null heading on review with no title', () => { + const item = new TestViewMedia({ + mediaType: ViewMediaType.Review, + title: null, + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + expect(controller.getHeading()).toBeNull(); + }); + + it('should calculate with null item', () => { + const controller = new MediaDetailsController(); + controller.calculate(null, undefined); + expect(controller.getHeading()).toBeNull(); + expect(controller.getDetails()).toEqual([]); + }); + }); + + describe('should get message', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should get message', () => { + const item = new TestViewMedia({ + title: 'Test Title', + what: ['person'], + description: 'Test Description', + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + + const message = controller.getMessage(); + expect(message.heading?.title).toBe('Person'); + expect(message.details).toContainEqual({ + title: 'Test Title', + }); + expect(message.text).toBe('Test Description'); + }); + + it('should get message without media', () => { + const item = new ViewFolder(createFolder(), []); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + + const message = controller.getMessage(); + expect(message.text).toBeUndefined(); + }); + + it('should get message with null description', () => { + const item = new TestViewMedia({ + description: null, + }); + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + + const message = controller.getMessage(); + expect(message.text).toBeUndefined(); + }); + + it('should get message with controls', async () => { + const item = new TestViewMedia({ + title: 'Test Title', + mediaType: ViewMediaType.Review, + id: 'review_id', + startTime: new Date(), + }); + const viewManagerEpoch = mock(); + const cardAPI = createCardAPI(); + viewManagerEpoch.manager = cardAPI.getViewManager(); + const viewItemManager = mock(); + + const context = { + capabilities: { + canFavorite: true, + canDownload: true, + }, + viewItemManager: viewItemManager, + viewManagerEpoch: viewManagerEpoch, + }; + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + + const message = controller.getMessage(context); + const controls = message.controls; + expect(controls).toHaveLength(4); + + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // 1. Review control + expect(controls?.[0].title).toBe('Mark as reviewed'); + const reviewResult = await controls?.[0].callback?.(); + expect(reviewResult).not.toBeNull(); + + // 1b. Review control (failure) + viewItemManager.reviewMedia.mockRejectedValue(new Error('fail')); + + const reviewFailureResult = await controls?.[0].callback?.(); + expect(reviewFailureResult).toBeNull(); + + // 2. Favorite control + expect(controls?.[1].title).toBe('Media will be indefinitely retained'); + const favoriteResult = await controls?.[1].callback?.(); + expect(favoriteResult).not.toBeNull(); + + // 2b. Favorite control (failure) + viewItemManager.favorite.mockRejectedValue(new Error('fail')); + const favoriteFailureResult = await controls?.[1].callback?.(); + expect(favoriteFailureResult).toBeNull(); + + // 3. Download control + expect(controls?.[2].title).toBe('Download media'); + const downloadResult = await controls?.[2].callback?.(); + expect(downloadResult).toBeNull(); + + // 4. Timeline control + expect(controls?.[3].title).toBe('See media in timeline'); + const timelineResult = await controls?.[3].callback?.(); + expect(timelineResult).toBeNull(); + }); + + it('should get message with controls for already reviewed/favorited items', () => { + const item = new TestViewMedia({ + mediaType: ViewMediaType.Review, + reviewed: true, + favorite: true, + }); + const context = { + capabilities: { + canFavorite: true, + canDownload: false, + }, + }; + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + + const message = controller.getMessage(context); + const controls = message.controls; + expect(controls).toHaveLength(2); + + expect(controls?.[0].title).toBe('Mark as unreviewed'); + expect(controls?.[0].icon).toEqual({ icon: 'mdi:check-circle' }); + + expect(controls?.[1].emphasis).toBe('medium'); + expect(controls?.[1].icon).toEqual({ icon: 'mdi:star' }); + }); + + it('should get message with controls when item has no ID', () => { + const item = new TestViewMedia({ + id: null, + }); + const context = { + capabilities: { + canFavorite: false, + canDownload: true, + }, + }; + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + + const message = controller.getMessage(context); + expect(message.controls).toHaveLength(0); + }); + + it('should get message with controls when context has no capabilities', () => { + const item = new TestViewMedia({ + id: 'id', + }); + const context = {}; + + const controller = new MediaDetailsController(); + controller.calculate(null, item); + + const message = controller.getMessage(context); + expect(message.controls).toHaveLength(0); + }); + + it('should get empty controls when item is null', () => { + const controller = new MediaDetailsController(); + // Directly call protected method via casting to test the null item branch. + // Use cast to unknown first to avoid any-related lint errors. + const controls = ( + controller as unknown as { + _getControls: (context: OverlayControlsContext) => OverlayMessageControl[]; + } + )._getControls({}); + expect(controls).toEqual([]); + }); + }); +}); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index 8c70a8b4..1a8d4c6f 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -5,6 +5,7 @@ import { Capabilities } from '../../src/camera-manager/capabilities.js'; import { CameraManager } from '../../src/camera-manager/manager.js'; import { CameraManagerCameraMetadata } from '../../src/camera-manager/types.js'; import { FoldersManager } from '../../src/card-controller/folders/manager.js'; +import { FolderQuery } from '../../src/card-controller/folders/types'; import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager.js'; import { MediaPlayerManager } from '../../src/card-controller/media-player-manager.js'; import { MicrophoneManager } from '../../src/card-controller/microphone-manager.js'; @@ -18,11 +19,12 @@ import { ViewDisplayMode } from '../../src/config/schema/common/display.js'; import { MenuItem } from '../../src/config/schema/elements/custom/menu/types.js'; import { AdvancedCameraCardConfig } from '../../src/config/schema/types.js'; import { HomeAssistant } from '../../src/ha/types.js'; +import { QuerySource } from '../../src/query-source'; import { MediaPlayerController, PTZMovementType } from '../../src/types.js'; import { createGeneralAction, createViewAction } from '../../src/utils/action.js'; import { ViewMedia, ViewMediaType } from '../../src/view/item.js'; import { QueryResults } from '../../src/view/query-results.js'; -import { FolderViewQuery } from '../../src/view/query.js'; +import { UnifiedQuery } from '../../src/view/unified-query.js'; import { getCameraIDsForViewName, isViewSupportedByCamera, @@ -559,7 +561,9 @@ describe('MenuButtonController', () => { describe('should have clips menu button', () => { it('when in clips view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockImplementation( + (view) => view !== 'reviews', + ); const buttons = calculateButtons(controller, { view: createView({ view: 'clips' }), viewManager: viewManager, @@ -579,7 +583,9 @@ describe('MenuButtonController', () => { it('when not in clips view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockImplementation( + (view) => view !== 'reviews', + ); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -607,12 +613,38 @@ describe('MenuButtonController', () => { expect.arrayContaining([expect.objectContaining({ title: 'Clips gallery' })]), ); }); + + it('should be hidden when reviews are supported', () => { + vi.mocked(isViewSupportedByCamera).mockImplementation( + (view) => view === 'clips' || view === 'reviews', + ); + const buttons = calculateButtons(controller); + + expect(buttons).not.toEqual( + expect.arrayContaining([expect.objectContaining({ title: 'Clips gallery' })]), + ); + }); + + it('should be shown when reviews are supported but button is explicitly enabled', () => { + vi.mocked(isViewSupportedByCamera).mockImplementation( + (view) => view === 'clips' || view === 'reviews', + ); + const buttons = calculateButtons(controller, { + config: createConfig({ menu: { buttons: { clips: { enabled: true } } } }), + }); + + expect(buttons).toContainEqual( + expect.objectContaining({ title: 'Clips gallery', enabled: true }), + ); + }); }); describe('should have snapshots menu button', () => { it('when in snapshots view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockImplementation( + (view) => view !== 'reviews', + ); const buttons = calculateButtons(controller, { view: createView({ view: 'snapshots' }), viewManager: viewManager, @@ -638,7 +670,9 @@ describe('MenuButtonController', () => { it('when not in snapshots view', () => { const viewManager = mock(); - vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(isViewSupportedByCamera).mockImplementation( + (view) => view !== 'reviews', + ); const buttons = calculateButtons(controller, { viewManager: viewManager, }); @@ -674,6 +708,97 @@ describe('MenuButtonController', () => { ]), ); }); + + it('should be hidden when reviews are supported', () => { + vi.mocked(isViewSupportedByCamera).mockImplementation( + (view) => view === 'snapshots' || view === 'reviews', + ); + const buttons = calculateButtons(controller); + + expect(buttons).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: 'Snapshots gallery' }), + ]), + ); + }); + + it('should be shown when reviews are supported but button is explicitly enabled', () => { + vi.mocked(isViewSupportedByCamera).mockImplementation( + (view) => view === 'snapshots' || view === 'reviews', + ); + const buttons = calculateButtons(controller, { + config: createConfig({ menu: { buttons: { snapshots: { enabled: true } } } }), + }); + + expect(buttons).toContainEqual( + expect.objectContaining({ title: 'Snapshots gallery', enabled: true }), + ); + }); + }); + + describe('should have reviews menu button', () => { + it('when in reviews view', () => { + const viewManager = mock(); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + const buttons = calculateButtons(controller, { + view: createView({ view: 'reviews' }), + viewManager: viewManager, + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:play-box-multiple', + enabled: true, + priority: 50, + type: 'custom:advanced-camera-card-menu-icon', + title: 'Reviews gallery', + style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, + tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'reviews', + }, + hold_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'review', + }, + }); + }); + + it('when not in reviews view', () => { + const viewManager = mock(); + vi.mocked(isViewSupportedByCamera).mockReturnValue(true); + const buttons = calculateButtons(controller, { + viewManager: viewManager, + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:play-box-multiple', + enabled: true, + priority: 50, + type: 'custom:advanced-camera-card-menu-icon', + title: 'Reviews gallery', + style: {}, + tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'reviews', + }, + hold_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'review', + }, + }); + }); + + it('when not supported', () => { + const viewManager = mock(); + vi.mocked(isViewSupportedByCamera).mockReturnValue(false); + const buttons = calculateButtons(controller, { + viewManager: viewManager, + }); + + expect(buttons).not.toEqual( + expect.arrayContaining([expect.objectContaining({ title: 'Reviews gallery' })]), + ); + }); }); describe('should have recordings menu button', () => { @@ -1838,11 +1963,16 @@ describe('MenuButtonController', () => { new Map([['folder-0', folder]]).entries(), ); + const folderNode: FolderQuery = { + source: QuerySource.Folder, + folder: folder, + path: [{ ha: { id: 'one' } }], + }; const buttons = calculateButtons(controller, { foldersManager, view: createView({ view: 'folder', - query: new FolderViewQuery({ folder, path: [{ ha: { id: 'one' } }] }), + query: new UnifiedQuery().addNode(folderNode), }), }); @@ -1869,12 +1999,14 @@ describe('MenuButtonController', () => { ]); foldersManager.getFolders.mockReturnValue(folders.entries()); + const selectedFolderNode: FolderQuery = { + source: QuerySource.Folder, + folder: selectedFolder, + path: [{ ha: { id: 'id' } }], + }; const view = createView({ view: 'folder', - query: new FolderViewQuery({ - folder: selectedFolder, - path: [{ ha: { id: 'id' } }], - }), + query: new UnifiedQuery().addNode(selectedFolderNode), }); const buttons = calculateButtons(controller, { @@ -2123,4 +2255,86 @@ describe('MenuButtonController', () => { }); }); }); + + describe('should have set review button', () => { + it('when unreviewed', () => { + const selectedItem = new TestViewMedia({ + mediaType: ViewMediaType.Review, + reviewed: false, + }); + + const queryResults = mock(); + queryResults.getSelectedResult.mockReturnValue(selectedItem); + + const view = createView({ + view: 'media', + queryResults: queryResults, + }); + + const buttons = calculateButtons(controller, { view: view }); + + expect(buttons).toContainEqual( + expect.objectContaining({ + icon: 'mdi:check-circle-outline', + title: 'Mark as reviewed', + }), + ); + }); + + it('when already reviewed', () => { + const selectedItem = new TestViewMedia({ + mediaType: ViewMediaType.Review, + reviewed: true, + }); + + const queryResults = mock(); + queryResults.getSelectedResult.mockReturnValue(selectedItem); + + const view = createView({ + view: 'media', + queryResults: queryResults, + }); + + const buttons = calculateButtons(controller, { view: view }); + + expect(buttons).toContainEqual( + expect.objectContaining({ + icon: 'mdi:check-circle', + title: 'Mark as unreviewed', + }), + ); + }); + + it('when isReviewed returns null', () => { + const selectedItem = new TestViewMedia({ + mediaType: ViewMediaType.Review, + reviewed: null, + }); + + const queryResults = mock(); + queryResults.getSelectedResult.mockReturnValue(selectedItem); + + const view = createView({ + view: 'media', + queryResults: queryResults, + }); + + const buttons = calculateButtons(controller, { view: view }); + + expect(buttons).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + icon: 'mdi:check-circle', + }), + ]), + ); + expect(buttons).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + icon: 'mdi:check-circle-outline', + }), + ]), + ); + }); + }); }); diff --git a/tests/components-lib/navigation.test.ts b/tests/components-lib/navigation.test.ts new file mode 100644 index 00000000..5f52e362 --- /dev/null +++ b/tests/components-lib/navigation.test.ts @@ -0,0 +1,429 @@ +import { NonEmptyTuple } from 'type-fest'; +import { assert, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CameraManager } from '../../src/camera-manager/manager'; +import { EventQuery, QueryType } from '../../src/camera-manager/types'; +import { FoldersManager } from '../../src/card-controller/folders/manager'; +import { FolderPathComponent } from '../../src/card-controller/folders/types'; +import { ViewManagerEpoch, ViewModifier } from '../../src/card-controller/view/types'; +import { + FolderNavigationParamaters, + MediaNavigationParamaters, + getUpFolderItem, + navigateToFolder, + navigateToMedia, + navigateUp, +} from '../../src/components-lib/navigation'; +import { QuerySource } from '../../src/query-source'; +import { ViewFolder, ViewMedia } from '../../src/view/item'; +import { UnifiedQuery } from '../../src/view/unified-query'; +import { UnifiedQueryBuilder } from '../../src/view/unified-query-builder'; +import { + createCardAPI, + createFolder, + createView, + createViewWithMedia, +} from '../test-utils'; + +const createFolderQuery = ( + folder: ReturnType, + path: NonEmptyTuple = [{}], +): UnifiedQuery => { + const query = new UnifiedQuery(); + query.addNode({ + source: QuerySource.Folder, + folder, + path, + }); + return query; +}; + +const createCameraQuery = (): UnifiedQuery => { + const query = new UnifiedQuery(); + const eventNode: EventQuery = { + source: QuerySource.Camera, + type: QueryType.Event, + cameraIDs: new Set(['camera1']), + hasClip: true, + }; + query.addNode(eventNode); + return query; +}; + +describe('navigateUp', () => { + it('should do nothing with null options', () => { + navigateUp(null); + + // No error thrown + }); + + it('should ignore non-folder query', () => { + const api = createCardAPI(); + const view = createView({ + query: createCameraQuery(), + }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const epoch: ViewManagerEpoch = { + manager: api.getViewManager(), + }; + + const builder = new UnifiedQueryBuilder( + mock(), + mock(), + ); + const options: FolderNavigationParamaters = { + builder, + viewManagerEpoch: epoch, + }; + + navigateUp(options); + + expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled(); + }); + + it('should ignore folder query without parent to go up to', () => { + const api = createCardAPI(); + const folder = createFolder(); + const view = createView({ + query: createFolderQuery(folder, [{ ha: { id: 'root' } }]), + }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const epoch: ViewManagerEpoch = { + manager: api.getViewManager(), + }; + + const builder = new UnifiedQueryBuilder( + mock(), + mock(), + ); + const options: FolderNavigationParamaters = { + builder, + viewManagerEpoch: epoch, + }; + + navigateUp(options); + + expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled(); + }); + + it('should go up in the folder hierarchy', () => { + const api = createCardAPI(); + const folder = createFolder(); + const view = createView({ + query: createFolderQuery(folder, [ + { ha: { id: 'one' } }, + { ha: { id: 'two' } }, + { ha: { id: 'three' } }, + ]), + }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const epoch: ViewManagerEpoch = { + manager: api.getViewManager(), + }; + + const builder = new UnifiedQueryBuilder( + mock(), + mock(), + ); + const options: FolderNavigationParamaters = { + builder, + viewManagerEpoch: epoch, + }; + + navigateUp(options); + + expect(api.getViewManager().setViewByParametersWithExistingQuery).toBeCalledWith({ + params: { + query: expect.any(UnifiedQuery), + }, + }); + + const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery) + .mock.calls[0][0]?.params?.query as UnifiedQuery; + const nodes = query.getNodes(); + expect(nodes).toHaveLength(1); + expect(nodes[0]).toMatchObject({ + source: QuerySource.Folder, + folder, + path: [{ ha: { id: 'one' } }, { ha: { id: 'two' } }], + }); + }); + + it('should go up in the folder hierarchy with limit', () => { + const api = createCardAPI(); + const folder = createFolder(); + const view = createView({ + query: createFolderQuery(folder, [{ ha: { id: 'one' } }, { ha: { id: 'two' } }]), + }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const epoch: ViewManagerEpoch = { + manager: api.getViewManager(), + }; + + const builder = new UnifiedQueryBuilder( + mock(), + mock(), + ); + const options: FolderNavigationParamaters = { + builder, + viewManagerEpoch: epoch, + limit: 50, + }; + + navigateUp(options); + + const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery) + .mock.calls[0][0]?.params?.query as UnifiedQuery; + expect(query.getNodes()[0].limit).toBe(50); + }); +}); + +describe('navigateToFolder', () => { + it('should do nothing with null options', () => { + const folder = createFolder(); + const item = new ViewFolder(folder, [{ ha: { id: 'root' } }]); + + navigateToFolder(item, null); + + // No error thrown + }); + + it('should navigate into folder', () => { + const api = createCardAPI(); + const folder = createFolder(); + const view = createView({ + query: createFolderQuery(folder, [{ ha: { id: 'root' } }]), + }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const epoch: ViewManagerEpoch = { + manager: api.getViewManager(), + }; + + const builder = new UnifiedQueryBuilder( + mock(), + mock(), + ); + const options: FolderNavigationParamaters = { + builder, + viewManagerEpoch: epoch, + }; + + const item = new ViewFolder(folder, [{ ha: { id: 'root' } }]); + navigateToFolder(item, options); + + expect(api.getViewManager().setViewByParametersWithExistingQuery).toBeCalledWith({ + params: { + query: expect.any(UnifiedQuery), + }, + }); + + const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery) + .mock.calls[0][0]?.params?.query; + const nodes = query?.getNodes(); + expect(nodes).toHaveLength(1); + expect(nodes?.[0]).toMatchObject({ + source: QuerySource.Folder, + folder, + }); + expect(nodes?.[0]).toHaveProperty('path'); + expect((nodes?.[0] as { path: readonly unknown[] }).path).toHaveLength(2); + }); + + it('should navigate into folder with limit', () => { + const api = createCardAPI(); + const folder = createFolder(); + const epoch: ViewManagerEpoch = { + manager: api.getViewManager(), + }; + + const builder = new UnifiedQueryBuilder( + mock(), + mock(), + ); + const options: FolderNavigationParamaters = { + builder, + viewManagerEpoch: epoch, + limit: 100, + }; + + const item = new ViewFolder(folder, [{ ha: { id: 'root' } }]); + navigateToFolder(item, options); + + const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery) + .mock.calls[0][0]?.params?.query; + expect(query?.getNodes()[0].limit).toBe(100); + }); +}); + +describe('getUpFolderItem', () => { + it('should return null for null query', () => { + expect(getUpFolderItem(null)).toBeNull(); + }); + + it('should return null for non-folder query', () => { + expect(getUpFolderItem(createCameraQuery())).toBeNull(); + }); + + it('should return null for folder query with single path element', () => { + const folder = createFolder(); + expect( + getUpFolderItem(createFolderQuery(folder, [{ ha: { id: 'root' } }])), + ).toBeNull(); + }); + + it('should return ViewFolder for navigable folder query', () => { + const folder = createFolder(); + const query = createFolderQuery(folder, [ + { ha: { id: 'one' } }, + { ha: { id: 'two' } }, + { ha: { id: 'three' } }, + ]); + + const folderItem = getUpFolderItem(query); + + expect(folderItem).toBeInstanceOf(ViewFolder); + expect(folderItem?.getIcon()).toBe('mdi:arrow-up-left'); + }); +}); + +describe('navigateToMedia', () => { + it('should do nothing with null options', () => { + navigateToMedia(mock(), null); + // No error thrown + }); + + it('should navigate with viewManagerEpoch', () => { + const api = createCardAPI(); + const view = createViewWithMedia(); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const epoch: ViewManagerEpoch = { + manager: api.getViewManager(), + }; + + const media = mock(); + const options: MediaNavigationParamaters = { + viewManagerEpoch: epoch, + }; + + navigateToMedia(media, options); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + view: 'media', + queryResults: expect.anything(), + }), + }), + ); + }); + + it('should select the correct media', () => { + const api = createCardAPI(); + const view = createViewWithMedia(); + const media = view.queryResults?.getResult(2); + + assert(media instanceof ViewMedia); + + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const options: MediaNavigationParamaters = { + viewManagerEpoch: { + manager: api.getViewManager(), + }, + }; + + navigateToMedia(media, options); + + const call = vi.mocked(api.getViewManager().setViewByParameters).mock.calls[0]?.[0]; + expect(call?.params?.queryResults?.getSelectedIndex()).toBe(2); + }); + + it('should set camera', () => { + const api = createCardAPI(); + const view = createViewWithMedia(); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const media = mock(); + vi.mocked(media.getCameraID).mockReturnValue('camera1'); + + const options: MediaNavigationParamaters = { + viewManagerEpoch: { + manager: api.getViewManager(), + }, + }; + + navigateToMedia(media, options); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + view: 'media', + queryResults: expect.anything(), + camera: 'camera1', + }), + }), + ); + }); + + it('should navigate with modifiers', () => { + const api = createCardAPI(); + const view = createViewWithMedia(); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const media = mock(); + const modifier = mock(); + const options: MediaNavigationParamaters = { + viewManagerEpoch: { + manager: api.getViewManager(), + }, + modifiers: [modifier], + }; + + navigateToMedia(media, options); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + modifiers: [modifier], + }), + ); + }); + + it('should do nothing if queryResults are missing', () => { + const api = createCardAPI(); + const view = createView(); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const media = mock(); + const options: MediaNavigationParamaters = { + viewManagerEpoch: { + manager: api.getViewManager(), + }, + }; + + navigateToMedia(media, options); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + + it('should do nothing if view is missing', () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(null); + + const media = mock(); + const options: MediaNavigationParamaters = { + viewManagerEpoch: { + manager: api.getViewManager(), + }, + }; + + navigateToMedia(media, options); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); +}); diff --git a/tests/components-lib/thumbnail/details-controller.test.ts b/tests/components-lib/thumbnail/details-controller.test.ts deleted file mode 100644 index 07f5afa2..00000000 --- a/tests/components-lib/thumbnail/details-controller.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { format } from 'date-fns'; -import { describe, expect, it } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import { CameraManager } from '../../../src/camera-manager/manager'; -import { ThumbnailDetailsController } from '../../../src/components-lib/thumbnail/details-controller'; -import { formatDateAndTime } from '../../../src/utils/basic'; -import { ViewFolder, ViewMediaType } from '../../../src/view/item'; -import { createFolder, TestViewMedia } from '../../test-utils'; - -describe('ThumbnailDetailsController', () => { - describe('should set heading', () => { - it('should set heading on event with what, tags and score', () => { - const item = new TestViewMedia({ - what: ['person', 'car'], - tags: ['tag1', 'tag2'], - score: 0.5, - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item); - expect(controller.getHeading()).toBe('Person, Car: Tag1, Tag2 50.00%'); - }); - - it('should set heading on event with tags', () => { - const item = new TestViewMedia({ - tags: ['tag1', 'tag2'], - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item); - expect(controller.getHeading()).toBe('Tag1, Tag2'); - }); - - it('should set heading on event with what', () => { - const item = new TestViewMedia({ - what: ['person', 'car'], - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item); - expect(controller.getHeading()).toBe('Person, Car'); - }); - - it('should set null heading on event with no other information', () => { - const item = new TestViewMedia({ - mediaType: ViewMediaType.Snapshot, - what: null, - tags: null, - score: null, - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item); - expect(controller.getHeading()).toBeNull(); - }); - - it('should set heading on recording with camera metadata', () => { - const cameraManager = mock(); - cameraManager.getCameraMetadata.mockReturnValue({ - title: 'Camera Title', - icon: { icon: 'mdi:cow' }, - }); - - const item = new TestViewMedia({ - mediaType: ViewMediaType.Recording, - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(cameraManager, item); - expect(controller.getHeading()).toBe('Camera Title'); - }); - - it('should set heading on recording without camera metadata', () => { - const item = new TestViewMedia({ - mediaType: ViewMediaType.Recording, - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item); - expect(controller.getHeading()).toBeNull(); - }); - - it('should set no heading on folder', () => { - const item = new ViewFolder(createFolder()); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item); - expect(controller.getHeading()).toBeNull(); - }); - }); - - describe('should set details', () => { - describe('should have title in details', () => { - it('should have icon with title when there are other details', () => { - const item = new TestViewMedia({ - title: 'Test Event', - where: ['where1', 'where2'], - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item); - expect(controller.getDetails()).toContainEqual({ - title: 'Test Event', - icon: { icon: 'mdi:rename' }, - hint: 'Title', - }); - }); - - it('should not have icon with title when there are no other details', () => { - const item = new TestViewMedia({ - title: 'Test Event', - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item); - expect(controller.getDetails()).toEqual([ - { - title: 'Test Event', - }, - ]); - }); - - it('should not have title with a start time', () => { - const item = new TestViewMedia({ - title: 'Test Event', - startTime: new Date('2025-05-22T21:12:00Z'), - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item); - expect(controller.getDetails()).not.toContainEqual( - expect.objectContaining({ - title: 'Test Event', - }), - ); - }); - }); - - it('should have start time in details', () => { - const startTime = new Date('2025-05-18T17:03:00Z'); - const item = new TestViewMedia({ - startTime, - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item); - - // Use formatDateAndTime to generate expected value (formats in local time with seconds) - expect(controller.getDetails()).toContainEqual({ - title: formatDateAndTime(startTime, true), - hint: 'Start', - icon: { icon: 'mdi:calendar-clock-outline' }, - }); - }); - - describe('should have duration in details', () => { - it('should have duration in details', () => { - const item = new TestViewMedia({ - startTime: new Date('2025-05-18T17:03:00Z'), - endTime: new Date('2025-05-18T17:04:00Z'), - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item); - expect(controller.getDetails()).toContainEqual({ - title: '1m 0s', - hint: 'Duration', - icon: { icon: 'mdi:clock-outline' }, - }); - }); - - it('should have in-progress in details', () => { - const item = new TestViewMedia({ - startTime: new Date('2025-05-18T17:03:00Z'), - endTime: null, - inProgress: true, - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item); - expect(controller.getDetails()).toContainEqual({ - title: 'In Progress', - hint: 'Duration', - icon: { icon: 'mdi:clock-outline' }, - }); - }); - - it('should have duration and in-progress in details', () => { - const item = new TestViewMedia({ - startTime: new Date('2025-05-18T17:03:00Z'), - endTime: new Date('2025-05-18T17:04:00Z'), - inProgress: true, - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item); - expect(controller.getDetails()).toContainEqual({ - title: '1m 0s In Progress', - hint: 'Duration', - icon: { icon: 'mdi:clock-outline' }, - }); - }); - }); - - it('should have camera title in details', () => { - const cameraManager = mock(); - cameraManager.getCameraMetadata.mockReturnValue({ - title: 'Camera Title', - icon: { icon: 'mdi:cow' }, - }); - - const item = new TestViewMedia({ - cameraID: 'camera_1', - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(cameraManager, item); - expect(controller.getDetails()).toContainEqual({ - title: 'Camera Title', - hint: 'Camera', - icon: { icon: 'mdi:cctv' }, - }); - }); - - it('should have where in details', () => { - const item = new TestViewMedia({ - cameraID: 'camera_1', - where: ['where1', 'where2'], - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item); - expect(controller.getDetails()).toContainEqual({ - title: 'Where1, Where2', - hint: 'Where', - icon: { icon: 'mdi:map-marker-outline' }, - }); - }); - - it('should have tags in details', () => { - const item = new TestViewMedia({ - cameraID: 'camera_1', - tags: ['tag1', 'tag2'], - }); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item); - expect(controller.getDetails()).toContainEqual({ - title: 'Tag1, Tag2', - hint: 'Tag', - icon: { icon: 'mdi:tag' }, - }); - }); - - it('should have seek in details', () => { - const item = new TestViewMedia(); - const seekTime = new Date('2025-05-20T07:14:57Z'); - - const controller = new ThumbnailDetailsController(); - controller.calculate(null, item, seekTime); - - // Use format() to generate expected value (formats in local time) - expect(controller.getDetails()).toContainEqual({ - title: format(seekTime, 'HH:mm:ss'), - hint: 'Seek', - icon: { icon: 'mdi:clock-fast' }, - }); - }); - }); -}); diff --git a/tests/components-lib/thumbnail/feature/controller.test.ts b/tests/components-lib/thumbnail/feature/controller.test.ts index f6be0c0a..d39204e7 100644 --- a/tests/components-lib/thumbnail/feature/controller.test.ts +++ b/tests/components-lib/thumbnail/feature/controller.test.ts @@ -115,7 +115,7 @@ describe('ThumbnailFeatureController', () => { it('should not set subtitle on folder media', () => { const controller = new ThumbnailFeatureController(); - const itemWithThumbnail = new ViewFolder(createFolder(), { + const itemWithThumbnail = new ViewFolder(createFolder(), [], { title: 'Test Folder', }); @@ -152,7 +152,7 @@ describe('ThumbnailFeatureController', () => { }); describe('should set thumbnail', () => { - it('should set brand thumbnail', () => { + it('should set placeholder thumbnail', () => { const controller = new ThumbnailFeatureController(); const itemWithThumbnail = new TestViewMedia({ thumbnail: 'https://brands.home-assistant.io//amcrest/icon.png', @@ -163,7 +163,7 @@ describe('ThumbnailFeatureController', () => { expect(controller.getThumbnail()).toBe( 'https://brands.home-assistant.io/brands/_/amcrest/icon.png', ); - expect(controller.getThumbnailClass()).toBe('brand'); + expect(controller.getThumbnailClass()).toBe('placeholder'); }); it('should set other thumbnail', () => { diff --git a/tests/components-lib/timeline/source.test.ts b/tests/components-lib/timeline/source.test.ts index de8ed1f7..8be8c51b 100644 --- a/tests/components-lib/timeline/source.test.ts +++ b/tests/components-lib/timeline/source.test.ts @@ -1,7 +1,17 @@ import { add } from 'date-fns'; +import { NonEmptyTuple } from 'type-fest'; import { DataSet } from 'vis-data'; import { TimelineWindow } from 'vis-timeline'; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + afterAll, + assert, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CameraManager } from '../../../src/camera-manager/manager'; import { @@ -12,21 +22,25 @@ import { RecordingSegment, RecordingSegmentsQuery, RecordingSegmentsQueryResults, + ReviewQuery, } from '../../../src/camera-manager/types'; import { FoldersManager } from '../../../src/card-controller/folders/manager'; +import { + FolderPathComponent, + FolderQuery, +} from '../../../src/card-controller/folders/types'; import { AdvancedCameraCardTimelineItem, TimelineDataSource, } from '../../../src/components-lib/timeline/source'; -import { TimelineKeys } from '../../../src/components-lib/timeline/types'; import { ConditionStateManagerReadonlyInterface } from '../../../src/conditions/types'; +import { QuerySource } from '../../../src/query-source'; import { ViewMediaType } from '../../../src/view/item'; -import { FolderViewQuery } from '../../../src/view/query'; +import { QueryNode, UnifiedQuery } from '../../../src/view/unified-query'; import { createCameraManager, createFolder, createStore, - createView, TestViewMedia, } from '../../test-utils'; @@ -45,7 +59,7 @@ const testCameraMedia = new TestViewMedia({ endTime: end, }); -const folder = createFolder({ id: 'folder/folder-1', title: 'Folder Title' }); +const folder = createFolder({ id: 'folder-1', title: 'Folder Title' }); const testFolderMedia = new TestViewMedia({ folder, id: TEST_MEDIA_ID, @@ -67,6 +81,7 @@ const createTestCameraManager = (): CameraManager => { icon: { icon: 'mdi:camera' }, }); const eventQuery: EventQuery = { + source: QuerySource.Camera, type: QueryType.Event, cameraIDs: new Set([CAMERA_ID]), start: start, @@ -104,55 +119,71 @@ const createTestCameraManager = (): CameraManager => { }; describe('TimelineDataSource', () => { - const cameraTimelineKeys: TimelineKeys = { - type: 'camera', - cameraIDs: new Set(['camera-1', 'camera-2']), + // Create camera-based query with events for two cameras + const cameraEventsQuery = new UnifiedQuery(); + const eventQuery1: EventQuery = { + source: QuerySource.Camera, + type: QueryType.Event, + cameraIDs: new Set(['camera-1']), + hasClip: true, }; - const folderTimelineKeys: TimelineKeys = { - type: 'folder', - folder, + cameraEventsQuery.addNode(eventQuery1); + + const eventQuery2: EventQuery = { + source: QuerySource.Camera, + type: QueryType.Event, + cameraIDs: new Set(['camera-2']), + hasSnapshot: true, + }; + cameraEventsQuery.addNode(eventQuery2); + + // Create camera-based query with reviews for one camera + const reviewQuery = new UnifiedQuery(); + const reviewQueryNode: ReviewQuery = { + source: QuerySource.Camera, + type: QueryType.Review, + cameraIDs: new Set(['camera-1']), + }; + reviewQuery.addNode(reviewQueryNode); + + // Create folder-based query + const folderQuery = new UnifiedQuery(); + const folderPath: NonEmptyTuple = [{}]; + const folderQueryNode: FolderQuery = { + source: QuerySource.Folder, + folder: folder, + path: folderPath, + }; + folderQuery.addNode(folderQueryNode); + + // Helper to create a TimelineDataSource with a shape query + const createSource = ( + cameraManager: CameraManager, + foldersManager: FoldersManager, + conditionStateManager: ConditionStateManagerReadonlyInterface, + shape: UnifiedQuery, + showRecordings = true, + ): TimelineDataSource => { + return new TimelineDataSource( + cameraManager, + foldersManager, + conditionStateManager, + shape, + showRecordings, + ); }; beforeEach(() => { vi.clearAllMocks(); }); - describe('should get key type', () => { - it('should get camera key type', () => { - const source = new TimelineDataSource( - createTestCameraManager(), - mock(), - mock(), - cameraTimelineKeys, - 'all', - true, - ); - - expect(source.getKeyType()).toBe('camera'); - }); - - it('should get folder key type', () => { - const source = new TimelineDataSource( - createTestCameraManager(), - mock(), - mock(), - folderTimelineKeys, - 'all', - true, - ); - - expect(source.getKeyType()).toBe('folder'); - }); - }); - describe('should get groups', () => { it('should get camera based groups', () => { - const source = new TimelineDataSource( + const source = createSource( createTestCameraManager(), mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, true, ); @@ -168,12 +199,11 @@ describe('TimelineDataSource', () => { }); it('should get folder based groups', () => { - const source = new TimelineDataSource( + const source = createSource( createTestCameraManager(), mock(), mock(), - folderTimelineKeys, - 'all', + folderQuery, true, ); @@ -188,12 +218,11 @@ describe('TimelineDataSource', () => { const cameraManager = createTestCameraManager(); vi.mocked(cameraManager.getCameraMetadata).mockReturnValue(null); - const source = new TimelineDataSource( + const source = createSource( cameraManager, mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, true, ); @@ -204,35 +233,20 @@ describe('TimelineDataSource', () => { }); it('should use folder id if folder has no title', () => { - const folder = createFolder({ id: 'folder/folder-1' }); - const timelineKeys: TimelineKeys = { type: 'folder', folder }; - - const source = new TimelineDataSource( - createTestCameraManager(), - mock(), - mock(), - timelineKeys, - 'all', - true, - ); - - expect(source.groups.length).toBe(1); - expect(source.groups.get('folder/folder-1')).toEqual({ - content: 'folder/folder-1', - id: 'folder/folder-1', + const folder = createFolder({ id: 'folder-1' }); + const testQuery = new UnifiedQuery(); + const folderPath: NonEmptyTuple = [{}]; + testQuery.addNode({ + source: QuerySource.Folder, + folder: folder, + path: folderPath, }); - }); - it('should have no groups if a folder key has no folder', () => { - const folder = createFolder({ id: 'folder/folder-1' }); - const timelineKeys: TimelineKeys = { type: 'folder', folder }; - - const source = new TimelineDataSource( + const source = createSource( createTestCameraManager(), mock(), mock(), - timelineKeys, - 'all', + testQuery, true, ); @@ -256,15 +270,14 @@ describe('TimelineDataSource', () => { endTime, }); - const source = new TimelineDataSource( + const source = createSource( createTestCameraManager(), mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, true, ); - source.addEventMediaToDataset([media]); + source.addMediaToDataset(cameraEventsQuery, [media]); expect(source.dataset.length).toBe(1); expect(source.dataset.get(id)).toEqual({ @@ -275,6 +288,79 @@ describe('TimelineDataSource', () => { group: 'camera/camera-1', content: '', type: 'range', + query: cameraEventsQuery, + }); + }); + + it('should add review media with severity to dataset', () => { + const startTime = new Date('2025-09-21T15:32:21Z'); + const endTime = new Date('2025-09-21T15:35:28Z'); + const id = 'REVIEW_ID'; + const media = new TestViewMedia({ + cameraID: 'camera-1', + id, + startTime, + endTime, + mediaType: ViewMediaType.Review, + severity: 'high', + }); + + const source = createSource( + createTestCameraManager(), + mock(), + mock(), + reviewQuery, + false, + ); + source.addMediaToDataset(reviewQuery, [media]); + + expect(source.dataset.length).toBe(1); + expect(source.dataset.get(id)).toEqual({ + id, + start: startTime.getTime(), + end: endTime.getTime(), + media, + group: 'camera/camera-1', + content: '', + type: 'range', + query: reviewQuery, + severity: 'high', + }); + }); + + it('should add review media with null severity to dataset', () => { + const startTime = new Date('2025-09-21T15:32:21Z'); + const endTime = new Date('2025-09-21T15:35:28Z'); + const id = 'REVIEW_ID'; + const media = new TestViewMedia({ + cameraID: 'camera-1', + id, + startTime, + endTime, + mediaType: ViewMediaType.Review, + severity: null, + }); + + const source = createSource( + createTestCameraManager(), + mock(), + mock(), + reviewQuery, + false, + ); + source.addMediaToDataset(reviewQuery, [media]); + + expect(source.dataset.length).toBe(1); + expect(source.dataset.get(id)).toEqual({ + id, + start: startTime.getTime(), + end: endTime.getTime(), + media, + group: 'camera/camera-1', + content: '', + type: 'range', + query: reviewQuery, + severity: undefined, }); }); @@ -282,7 +368,7 @@ describe('TimelineDataSource', () => { const startTime = new Date('2025-09-21T15:32:21Z'); const endTime = new Date('2025-09-21T15:35:28Z'); const id = 'EVENT_ID'; - const folderID = 'folder/FOLDER_ID'; + const folderID = 'FOLDER_ID'; const folder = createFolder({ id: folderID }); const media = new TestViewMedia({ cameraID: null, @@ -292,15 +378,14 @@ describe('TimelineDataSource', () => { folder, }); - const source = new TimelineDataSource( + const source = createSource( createTestCameraManager(), mock(), mock(), - folderTimelineKeys, - 'all', + folderQuery, true, ); - source.addEventMediaToDataset([media]); + source.addMediaToDataset(folderQuery, [media]); expect(source.dataset.length).toBe(1); expect(source.dataset.get(id)).toEqual({ @@ -308,23 +393,23 @@ describe('TimelineDataSource', () => { start: startTime.getTime(), end: endTime.getTime(), media, - group: folderID, + group: `folder/${folderID}`, content: '', type: 'range', + query: folderQuery, }); }); it('should ignore non-events media', () => { - const source = new TimelineDataSource( + const source = createSource( createTestCameraManager(), mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, true, ); - source.addEventMediaToDataset([ + source.addMediaToDataset(cameraEventsQuery, [ new TestViewMedia({ mediaType: ViewMediaType.Recording, }), @@ -334,31 +419,29 @@ describe('TimelineDataSource', () => { }); it('should ignore null results', () => { - const source = new TimelineDataSource( + const source = createSource( createTestCameraManager(), mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, true, ); - source.addEventMediaToDataset(null); + source.addMediaToDataset(cameraEventsQuery, null); expect(source.dataset.length).toBe(0); }); it('should ignore media without camera or folder ownership', () => { - const source = new TimelineDataSource( + const source = createSource( createTestCameraManager(), mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, true, ); - source.addEventMediaToDataset([ + source.addMediaToDataset(cameraEventsQuery, [ new TestViewMedia({ cameraID: null, folder: null, @@ -383,17 +466,15 @@ describe('TimelineDataSource', () => { describe('should refresh events from camera', () => { it('should refresh events successfully', async () => { - const source = new TimelineDataSource( + const source = createSource( createTestCameraManager(), mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, false, ); - const view = createView(); - await source.refresh(window, { view }); + await source.refresh(window); expect(source.dataset.length).toBe(1); @@ -404,6 +485,7 @@ describe('TimelineDataSource', () => { end: new Date('2025-09-21T19:31:15Z').getTime(), media: testCameraMedia, type: 'range', + query: expect.any(UnifiedQuery), group: 'camera/camera-1', }); }); @@ -416,12 +498,11 @@ describe('TimelineDataSource', () => { new Error('Error fetching events'), ); - const source = new TimelineDataSource( + const source = createSource( cameraManager, mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, false, ); @@ -436,62 +517,42 @@ describe('TimelineDataSource', () => { it('should not refresh events when window is cached', async () => { const cameraManager = createTestCameraManager(); - const source = new TimelineDataSource( + const source = createSource( cameraManager, mock(), mock(), - cameraTimelineKeys, - 'all', - false, - ); - const view = createView(); - - await source.refresh(window, { view }); - expect(source.dataset.length).toBe(1); - - await source.refresh(window, { view }); - expect(source.dataset.length).toBe(1); - expect(cameraManager.executeMediaQueries).toHaveBeenCalledTimes(1); - }); - - it('should not refresh events when unable to create event queries', async () => { - const cameraManager = createTestCameraManager(); - vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue(null); - - const source = new TimelineDataSource( - cameraManager, - mock(), - mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, false, ); await source.refresh(window); - expect(source.dataset.length).toBe(0); + expect(source.dataset.length).toBe(1); + + await source.refresh(window); + expect(source.dataset.length).toBe(1); + expect(cameraManager.executeMediaQueries).toHaveBeenCalledTimes(1); }); }); describe('should refresh events from folder', () => { it('should refresh events successfully', async () => { const foldersManager = mock(); - vi.mocked(foldersManager.generateDefaultFolderQuery).mockReturnValue({ + vi.mocked(foldersManager.getDefaultQueryParameters).mockReturnValue({ + source: QuerySource.Folder, folder, path: [{}], }); vi.mocked(foldersManager.expandFolder).mockResolvedValue([testFolderMedia]); - const source = new TimelineDataSource( + const source = createSource( mock(), foldersManager, mock(), - folderTimelineKeys, - 'all', + folderQuery, false, ); - const view = createView(); - await source.refresh(window, { view }); + await source.refresh(window); expect(source.dataset.length).toBe(1); @@ -503,7 +564,7 @@ describe('TimelineDataSource', () => { media: testFolderMedia, type: 'range', group: 'folder/folder-1', - query: expect.any(FolderViewQuery), + query: expect.any(UnifiedQuery), }); }); @@ -518,25 +579,24 @@ describe('TimelineDataSource', () => { it('should refresh events only when not cached', async () => { const foldersManager = mock(); - vi.mocked(foldersManager.generateDefaultFolderQuery).mockReturnValue({ + vi.mocked(foldersManager.getDefaultQueryParameters).mockReturnValue({ + source: QuerySource.Folder, folder, path: [{}], }); vi.mocked(foldersManager.expandFolder).mockResolvedValue([testFolderMedia]); - const source = new TimelineDataSource( + const source = createSource( mock(), foldersManager, mock(), - folderTimelineKeys, - 'all', + folderQuery, false, ); - const view = createView(); - await source.refresh(window, { view }); - await source.refresh(window, { view }); - await source.refresh(window, { view }); + await source.refresh(window); + await source.refresh(window); + await source.refresh(window); expect(foldersManager.expandFolder).toHaveBeenCalledTimes(1); }); @@ -545,27 +605,26 @@ describe('TimelineDataSource', () => { const start = new Date(); vi.setSystemTime(start); const foldersManager = mock(); - vi.mocked(foldersManager.generateDefaultFolderQuery).mockReturnValue({ + vi.mocked(foldersManager.getDefaultQueryParameters).mockReturnValue({ + source: QuerySource.Folder, folder, path: [{}], }); vi.mocked(foldersManager.expandFolder).mockResolvedValue([testFolderMedia]); - const source = new TimelineDataSource( + const source = createSource( mock(), foldersManager, mock(), - folderTimelineKeys, - 'all', + folderQuery, false, ); - const view = createView(); - await source.refresh(window, { view }); + await source.refresh(window); vi.setSystemTime(add(start, { hours: 1 })); - await source.refresh(window, { view }); + await source.refresh(window); expect(foldersManager.expandFolder).toHaveBeenCalledTimes(2); }); @@ -573,6 +632,84 @@ describe('TimelineDataSource', () => { }); }); + describe('should refresh reviews', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should refresh reviews successfully', async () => { + const cameraManager = createTestCameraManager(); + const cameraReviewQuery: ReviewQuery = { + source: QuerySource.Camera, + type: QueryType.Review, + cameraIDs: new Set([CAMERA_ID]), + start, + end, + }; + vi.mocked(cameraManager.generateDefaultReviewQueries).mockReturnValue([ + cameraReviewQuery, + ]); + + const source = createSource( + cameraManager, + mock(), + mock(), + reviewQuery, + false, + ); + + await source.refresh(window); + + expect(cameraManager.executeMediaQueries).toHaveBeenCalled(); + expect(source.dataset.length).toBe(1); + }); + + it('should not refresh reviews when window is cached', async () => { + const cameraManager = createTestCameraManager(); + const cameraReviewQuery: ReviewQuery = { + source: QuerySource.Camera, + type: QueryType.Review, + cameraIDs: new Set([CAMERA_ID]), + start, + end, + }; + vi.mocked(cameraManager.generateDefaultReviewQueries).mockReturnValue([ + cameraReviewQuery, + ]); + + const source = createSource( + cameraManager, + mock(), + mock(), + reviewQuery, + false, + ); + + await source.refresh(window); + await source.refresh(window); + + expect(cameraManager.executeMediaQueries).toHaveBeenCalledTimes(1); + }); + + it('should not refresh reviews without review queries', async () => { + const cameraManager = createTestCameraManager(); + vi.mocked(cameraManager.generateDefaultReviewQueries).mockReturnValue(null); + + const source = createSource( + cameraManager, + mock(), + mock(), + new UnifiedQuery(), + false, + ); + + await source.refresh(window); + + expect(cameraManager.executeMediaQueries).not.toHaveBeenCalled(); + expect(source.dataset.length).toBe(0); + }); + }); + describe('should refresh recordings', () => { const getRecordings = ( dataset: DataSet, @@ -581,12 +718,11 @@ describe('TimelineDataSource', () => { }; it('should refresh recordings successfully', async () => { - const source = new TimelineDataSource( + const source = createSource( createTestCameraManager(), mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, true, ); @@ -613,12 +749,11 @@ describe('TimelineDataSource', () => { new Error('Error fetching recordings'), ); - const source = new TimelineDataSource( + const source = createSource( cameraManager, mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, true, ); @@ -633,12 +768,11 @@ describe('TimelineDataSource', () => { it('should not refresh recordings when window is cached', async () => { const cameraManager = createTestCameraManager(); - const source = new TimelineDataSource( + const source = createSource( cameraManager, mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, true, ); @@ -652,12 +786,11 @@ describe('TimelineDataSource', () => { }); it('should not refresh recordings when recordings disabled', async () => { - const source = new TimelineDataSource( + const source = createSource( createTestCameraManager(), mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, // Disable recordings. false, @@ -669,12 +802,11 @@ describe('TimelineDataSource', () => { }); it('should not refresh recordings with folders', async () => { - const source = new TimelineDataSource( + const source = createSource( createTestCameraManager(), mock(), mock(), - folderTimelineKeys, - 'all', + folderQuery, true, ); @@ -684,16 +816,13 @@ describe('TimelineDataSource', () => { }); it('should not refresh recordings without cameras', async () => { - const emptycameraTimelineKeys: TimelineKeys = { - type: 'camera', - cameraIDs: new Set(), - }; - const source = new TimelineDataSource( + // Create an empty query with no slices + const emptyQuery = new UnifiedQuery(); + const source = createSource( createTestCameraManager(), mock(), mock(), - emptycameraTimelineKeys, - 'all', + emptyQuery, true, ); @@ -708,12 +837,11 @@ describe('TimelineDataSource', () => { null, ); - const source = new TimelineDataSource( + const source = createSource( cameraManager, mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, true, ); @@ -757,12 +885,11 @@ describe('TimelineDataSource', () => { ]), ); - const source = new TimelineDataSource( + const source = createSource( cameraManager, mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, true, ); @@ -812,12 +939,11 @@ describe('TimelineDataSource', () => { new Map([[recordingSegmentQuery, recordingSegmentsQueryResults]]), ); - const source = new TimelineDataSource( + const source = createSource( cameraManager, mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, true, ); source.dataset.add({ @@ -894,12 +1020,11 @@ describe('TimelineDataSource', () => { ]), ); - const source = new TimelineDataSource( + const source = createSource( cameraManager, mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, true, ); @@ -928,170 +1053,13 @@ describe('TimelineDataSource', () => { }); }); - describe('should get timeline event queries', () => { - const window: TimelineWindow = { start, end }; - - it('should not event queries without cameras', () => { - const source = new TimelineDataSource( - createCameraManager(), - mock(), - mock(), - folderTimelineKeys, - 'all', - true, - ); - - expect(source.getTimelineEventQueries(window)).toBeNull(); - }); - - it('should get event queries for clips', () => { - const cameraManager = createCameraManager( - createStore([ - { - cameraID: CAMERA_ID, - }, - ]), - ); - const source = new TimelineDataSource( - cameraManager, - mock(), - mock(), - cameraTimelineKeys, - 'clips', - false, - ); - source.getTimelineEventQueries(window); - - expect(cameraManager.generateDefaultEventQueries).toBeCalledWith( - new Set(['camera-1', 'camera-2']), - { - start, - end, - hasClip: true, - }, - ); - }); - - it('should get event queries for snapshots', () => { - const cameraManager = createCameraManager( - createStore([ - { - cameraID: CAMERA_ID, - }, - ]), - ); - const source = new TimelineDataSource( - cameraManager, - mock(), - mock(), - cameraTimelineKeys, - 'snapshots', - false, - ); - source.getTimelineEventQueries(window); - - expect(cameraManager.generateDefaultEventQueries).toBeCalledWith( - new Set(['camera-1', 'camera-2']), - { - start, - end, - hasSnapshot: true, - }, - ); - }); - }); - - describe('should get timeline folder queries', () => { - it('should not get folder queries without a folder', () => { - const source = new TimelineDataSource( - createCameraManager(), - mock(), - mock(), - cameraTimelineKeys, - 'all', - true, - ); - - expect(source.getTimelineFolderQuery()).toBeNull(); - }); - - it('should get folder queries', () => { - const foldersManager = mock(); - vi.mocked(foldersManager.generateDefaultFolderQuery).mockReturnValue({ - folder, - path: [{}], - }); - - const source = new TimelineDataSource( - mock(), - foldersManager, - mock(), - folderTimelineKeys, - 'clips', - false, - ); - - source.getTimelineFolderQuery(); - - expect(foldersManager.generateDefaultFolderQuery).toBeCalledWith(folder); - }); - }); - - describe('should get timeline recording queries', () => { - const window: TimelineWindow = { start, end }; - - it('should not recording queries without cameras', () => { - const source = new TimelineDataSource( - createCameraManager(), - mock(), - mock(), - folderTimelineKeys, - 'all', - true, - ); - - expect(source.getTimelineRecordingQueries(window)).toBeNull(); - }); - - it('should get recording queries', () => { - const cameraManager = createCameraManager( - createStore([ - { - cameraID: 'camera-1', - }, - { - cameraID: 'camera-2', - }, - ]), - ); - const source = new TimelineDataSource( - cameraManager, - mock(), - mock(), - cameraTimelineKeys, - 'all', - true, - ); - source.getTimelineRecordingQueries(window); - - expect(cameraManager.generateDefaultRecordingQueries).toBeCalledWith( - new Set(['camera-1', 'camera-2']), - { - start, - end, - }, - ); - }); - }); - describe('should rewrite event', () => { it('should not rewrite when item is not found', () => { - const source = new TimelineDataSource( + const source = createSource( createTestCameraManager(), mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, true, ); source.rewriteEvent('UNKNOWN_ID'); @@ -1100,12 +1068,11 @@ describe('TimelineDataSource', () => { }); it('should not rewrite when item is not found', () => { - const source = new TimelineDataSource( + const source = createSource( createTestCameraManager(), mock(), mock(), - cameraTimelineKeys, - 'all', + cameraEventsQuery, true, ); const item = { @@ -1116,6 +1083,7 @@ describe('TimelineDataSource', () => { group: 'camera/camera-1' as const, content: '', type: 'range' as const, + query: cameraEventsQuery, }; source.dataset.add(item); @@ -1124,4 +1092,172 @@ describe('TimelineDataSource', () => { expect(source.dataset.get('id')).toBe(item); }); }); + + describe('shape getter', () => { + it('should return the shape query', () => { + const source = createSource( + createTestCameraManager(), + mock(), + mock(), + cameraEventsQuery, + true, + ); + + const shape = source.shape; + + expect(shape).toBe(cameraEventsQuery); + }); + }); + + describe('areResultsFresh', () => { + it('should delegate to runner', () => { + const cameraManager = createTestCameraManager(); + vi.mocked(cameraManager.areMediaQueriesResultsFresh).mockReturnValue(true); + + const source = createSource( + cameraManager, + mock(), + mock(), + cameraEventsQuery, + true, + ); + + const result = source.areResultsFresh(new Date(), cameraEventsQuery); + + expect(result).toBe(true); + }); + + it('should return false when stale', () => { + const cameraManager = createTestCameraManager(); + vi.mocked(cameraManager.areMediaQueriesResultsFresh).mockReturnValue(false); + + const source = createSource( + cameraManager, + mock(), + mock(), + cameraEventsQuery, + true, + ); + + const result = source.areResultsFresh(new Date(), cameraEventsQuery); + + expect(result).toBe(false); + }); + }); + + describe('buildRecordingsWindowedQuery', () => { + it('should build recordings query with window', () => { + const source = createSource( + createTestCameraManager(), + mock(), + mock(), + cameraEventsQuery, + true, + ); + + const window = { start, end }; + const query = source.buildRecordingsWindowedQuery(window); + + expect(query).not.toBeNull(); + const mediaQueries = query?.getMediaQueries({ type: QueryType.Recording }); + expect(mediaQueries?.length).toBeGreaterThan(0); + expect(mediaQueries?.[0].start).toBe(start); + expect(mediaQueries?.[0].end).toBe(end); + }); + + it('should return null when no cameras', () => { + const emptyQuery = new UnifiedQuery(); + const source = createSource( + createTestCameraManager(), + mock(), + mock(), + emptyQuery, + true, + ); + + const query = source.buildRecordingsWindowedQuery({ start, end }); + + expect(query).toBeNull(); + }); + }); + + describe('hasClip/hasSnapshot in refresh', () => { + beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-15T12:00:00Z')); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + const isEventQuery = (query: QueryNode): query is EventQuery => + query.source === QuerySource.Camera && query.type === QueryType.Event; + + it('should set hasClip for clips media type', async () => { + const cameraManager = createTestCameraManager(); + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue([]); + + const clipsQuery = new UnifiedQuery(); + const queryNode: EventQuery = { + source: QuerySource.Camera, + type: QueryType.Event, + cameraIDs: new Set(['camera-1']), + hasClip: true, + }; + clipsQuery.addNode(queryNode); + + const source = createSource( + cameraManager, + mock(), + mock(), + clipsQuery, + true, + ); + + const window = { start, end }; + await source.refresh(window); + + expect(cameraManager.executeMediaQueries).toHaveBeenCalled(); + const call = vi.mocked(cameraManager.executeMediaQueries).mock.calls[0]; + const eventQuery = call[0][0]; + assert(isEventQuery(eventQuery)); + + expect(eventQuery.hasClip).toBe(true); + expect(eventQuery.hasSnapshot).toBeUndefined(); + }); + + it('should set hasSnapshot for snapshots media type', async () => { + const cameraManager = createTestCameraManager(); + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue([]); + + const snapshotsQuery = new UnifiedQuery(); + const queryNode: EventQuery = { + source: QuerySource.Camera, + type: QueryType.Event, + cameraIDs: new Set(['camera-1']), + hasSnapshot: true, + }; + snapshotsQuery.addNode(queryNode); + + const source = createSource( + cameraManager, + mock(), + mock(), + snapshotsQuery, + true, + ); + + const window = { start, end }; + await source.refresh(window); + + expect(cameraManager.executeMediaQueries).toHaveBeenCalled(); + const call = vi.mocked(cameraManager.executeMediaQueries).mock.calls[0]; + const eventQuery = call[0][0]; + assert(isEventQuery(eventQuery)); + + expect(eventQuery.hasSnapshot).toBe(true); + expect(eventQuery.hasClip).toBeUndefined(); + }); + }); }); diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index 143a8e95..df7663ee 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -1512,10 +1512,17 @@ describe('should handle version specific upgrades', () => { expect(config).toEqual({ type: 'custom:advanced-camera-card', cameras: [{}], + ...(mediaEventType !== 'all' && { + cameras_global: { + media: { + events_type: mediaEventType, + }, + }, + }), live: { controls: { thumbnails: { - events_media_type: mediaEventType, + // v8.0.0+ migration moves events_media_type to cameras_global . }, }, }, @@ -3587,4 +3594,101 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); }); + + describe('v8.0.0+', () => { + describe('live.controls.thumbnails.media_type -> cameras_global.media.type', () => { + it.each([['events' as const], ['recordings' as const]])( + '%s', + (mediaType: string) => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{}], + live: { + controls: { + thumbnails: { + media_type: mediaType, + }, + }, + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:advanced-camera-card', + cameras: [{}], + live: { + controls: { + thumbnails: {}, + }, + }, + cameras_global: { + media: { + type: mediaType, + }, + }, + }); + postUpgradeChecks(config); + }, + ); + }); + + describe('live.controls.thumbnails.events_media_type -> cameras_global.media.events_type', () => { + it.each([['clips' as const], ['snapshots' as const]])( + '%s', + (eventsType: string) => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{}], + live: { + controls: { + thumbnails: { + events_media_type: eventsType, + }, + }, + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:advanced-camera-card', + cameras: [{}], + live: { + controls: { + thumbnails: {}, + }, + }, + cameras_global: { + media: { + events_type: eventsType, + }, + }, + }); + postUpgradeChecks(config); + }, + ); + + it('all', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{}], + live: { + controls: { + thumbnails: { + events_media_type: 'all', + }, + }, + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:advanced-camera-card', + cameras: [{}], + live: { + controls: { + thumbnails: {}, + }, + }, + }); + postUpgradeChecks(config); + }); + }); + }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 7058755f..e238423e 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -53,10 +53,14 @@ describe('config defaults', () => { media_resolution: 'low', }, triggers: { - events: ['events', 'clips', 'snapshots'], + events: [], entities: [], motion: false, occupancy: false, + reviews: { + severities: ['high'], + description: true, + }, }, }, debug: { @@ -92,18 +96,17 @@ describe('config defaults', () => { position: 'bottom-right', }, thumbnails: { - media_type: 'events', - events_media_type: 'all', mode: 'right', show_details: true, - show_download_control: true, + show_download_control: false, show_favorite_control: true, - show_timeline_control: true, + show_info_control: true, + show_review_control: true, + show_timeline_control: false, size: 100, }, timeline: { clustering_threshold: 3, - events_media_type: 'all', format: { '24h': true, }, @@ -135,9 +138,11 @@ describe('config defaults', () => { }, thumbnails: { show_details: false, - show_download_control: true, + show_download_control: false, show_favorite_control: true, - show_timeline_control: true, + show_info_control: true, + show_review_control: true, + show_timeline_control: false, size: 100, }, }, @@ -164,14 +169,15 @@ describe('config defaults', () => { thumbnails: { mode: 'right', show_details: true, - show_download_control: true, + show_download_control: false, show_favorite_control: true, - show_timeline_control: true, + show_info_control: true, + show_review_control: true, + show_timeline_control: false, size: 100, }, timeline: { clustering_threshold: 3, - events_media_type: 'all', format: { '24h': true, }, @@ -202,7 +208,7 @@ describe('config defaults', () => { priority: 50, }, clips: { - enabled: true, + enabled: false, priority: 50, }, display_mode: { @@ -229,6 +235,10 @@ describe('config defaults', () => { enabled: false, priority: 50, }, + info: { + enabled: true, + priority: 50, + }, iris: { enabled: true, priority: 50, @@ -266,12 +276,20 @@ describe('config defaults', () => { enabled: false, priority: 50, }, + reviews: { + enabled: true, + priority: 50, + }, + set_review: { + enabled: true, + priority: 50, + }, screenshot: { enabled: false, priority: 50, }, snapshots: { - enabled: true, + enabled: false, priority: 50, }, substreams: { @@ -314,6 +332,10 @@ describe('config defaults', () => { enabled: true, priority: 50, }, + severity: { + enabled: true, + priority: 50, + }, technology: { enabled: true, priority: 50, @@ -333,13 +355,14 @@ describe('config defaults', () => { thumbnails: { mode: 'right', show_details: true, - show_download_control: true, + show_download_control: false, show_favorite_control: true, - show_timeline_control: true, + show_info_control: true, + show_review_control: true, + show_timeline_control: false, size: 100, }, }, - events_media_type: 'all', format: { '24h': true, }, diff --git a/tests/ha/browse-media/item-factory.test.ts b/tests/ha/browse-media/item-factory.test.ts index f4981727..2d527e36 100644 --- a/tests/ha/browse-media/item-factory.test.ts +++ b/tests/ha/browse-media/item-factory.test.ts @@ -21,6 +21,7 @@ describe('BrowseMediaViewItemFactory', () => { const browseMedia = createBrowseMedia({ can_expand: true }); const result = BrowseMediaViewItemFactory.create(browseMedia, { folder: createFolder(), + path: [], }); expect(result).toBeInstanceOf(BrowseMediaViewFolder); }); diff --git a/tests/ha/browse-media/item.test.ts b/tests/ha/browse-media/item.test.ts index 9fdc45a1..f7e14121 100644 --- a/tests/ha/browse-media/item.test.ts +++ b/tests/ha/browse-media/item.test.ts @@ -304,7 +304,7 @@ describe('BrowseMediaViewFolder', () => { const folder = createFolder(); const browseMedia = createBrowseMedia(); - const viewMedia = new BrowseMediaViewFolder(folder, browseMedia); + const viewMedia = new BrowseMediaViewFolder(folder, [], browseMedia); expect(viewMedia.getFolder()).toEqual(folder); }); @@ -314,7 +314,7 @@ describe('BrowseMediaViewFolder', () => { children_media_class: 'album', }); - const viewMedia = new BrowseMediaViewFolder(createFolder(), browseMedia); + const viewMedia = new BrowseMediaViewFolder(createFolder(), [], browseMedia); expect(viewMedia.getIcon()).toBe('mdi:album'); }); @@ -323,7 +323,7 @@ describe('BrowseMediaViewFolder', () => { children_media_class: 'unknown', }); - const viewMedia = new BrowseMediaViewFolder(createFolder(), browseMedia); + const viewMedia = new BrowseMediaViewFolder(createFolder(), [], browseMedia); expect(viewMedia.getIcon()).toBeNull(); }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 91228d4b..136f5569 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -6,11 +6,21 @@ import { mock } from 'vitest-mock-extended'; import { Camera } from '../src/camera-manager/camera'; import { Capabilities } from '../src/camera-manager/capabilities'; import { CameraManagerEngine } from '../src/camera-manager/engine'; -import { FrigateEvent, FrigateRecording } from '../src/camera-manager/frigate/types'; +import { + FrigateEvent, + FrigateRecording, + FrigateReview, +} from '../src/camera-manager/frigate/types'; import { GenericCameraManagerEngine } from '../src/camera-manager/generic/engine-generic'; import { CameraManager } from '../src/camera-manager/manager'; import { CameraManagerStore } from '../src/camera-manager/store'; -import { CameraEventCallback } from '../src/camera-manager/types'; +import { + CameraEventCallback, + EventQuery, + QueryType, + RecordingQuery, + ReviewQuery, +} from '../src/camera-manager/types'; import { ActionsManager } from '../src/card-controller/actions/actions-manager'; import { AutomationsManager } from '../src/card-controller/automations-manager'; import { CameraURLManager } from '../src/card-controller/camera-url-manager'; @@ -20,6 +30,7 @@ import { CardController } from '../src/card-controller/controller'; import { DefaultManager } from '../src/card-controller/default-manager'; import { ExpandManager } from '../src/card-controller/expand-manager'; import { FoldersManager } from '../src/card-controller/folders/manager'; +import { FolderQuery } from '../src/card-controller/folders/types'; import { FullscreenManager } from '../src/card-controller/fullscreen/fullscreen-manager'; import { HASSManager } from '../src/card-controller/hass/hass-manager'; import { StateWatcherSubscriptionInterface } from '../src/card-controller/hass/state-watcher'; @@ -30,6 +41,7 @@ import { MediaLoadedInfoManager } from '../src/card-controller/media-info-manage import { MediaPlayerManager } from '../src/card-controller/media-player-manager'; import { MessageManager } from '../src/card-controller/message-manager'; import { MicrophoneManager } from '../src/card-controller/microphone-manager'; +import { OverlayMessageManager } from '../src/card-controller/overlay-message-manager'; import { QueryStringManager } from '../src/card-controller/query-string-manager'; import { StatusBarItemManager } from '../src/card-controller/status-bar-item-manager'; import { StyleManager } from '../src/card-controller/style-manager'; @@ -57,13 +69,20 @@ import { import { Device } from '../src/ha/registry/device/types'; import { Entity, EntityRegistryManager } from '../src/ha/registry/entity/types'; import { CurrentUser, HassStateDifference, HomeAssistant } from '../src/ha/types'; +import { QuerySource } from '../src/query-source'; import { CapabilitiesRaw, EffectsControllerAPI, Interaction, MediaLoadedInfo, + Severity, } from '../src/types'; -import { EventViewMedia, ViewMedia, ViewMediaType } from '../src/view/item'; +import { + EventViewMedia, + ReviewViewMedia, + ViewMedia, + ViewMediaType, +} from '../src/view/item'; import { QueryResults } from '../src/view/query-results'; import { ViewItemCapabilities } from '../src/view/types'; import { View, ViewParameters } from '../src/view/view'; @@ -111,7 +130,9 @@ export const createHASS = (states?: HassEntities, user?: CurrentUser): HomeAssis hass.user = user; } hass.connection.subscribeMessage = vi.fn(); - hass.connection.sendMessagePromise = vi.fn(); + + // ha-nunjucks calls sendMessagePromise to fetch label registry; return empty array to prevent crash. + hass.connection.sendMessagePromise = vi.fn().mockResolvedValue([]); return hass; }; @@ -190,6 +211,24 @@ export const createFrigateRecording = (recording?: Partial) => }; }; +export const createFrigateReview = (review?: Partial) => { + return { + id: 'review_id', + camera: 'camera', + severity: 'alert' as const, + start_time: 1683395000, + end_time: 1683397124, + thumb_path: 'thumb.jpg', + has_been_reviewed: false, + data: { + objects: ['person'], + zones: [], + audio: [], + }, + ...review, + }; +}; + export const createView = (options?: Partial): View => { return new View({ view: 'live', @@ -321,7 +360,7 @@ export const generateViewMediaArray = (options?: { // ViewMedia itself has no native way to set startTime and ID that aren't linked // to an engine. -export class TestViewMedia extends ViewMedia implements EventViewMedia { +export class TestViewMedia extends ViewMedia implements EventViewMedia, ReviewViewMedia { protected _icon: string | null = null; protected _id: string | null; protected _startTime: Date | null; @@ -334,6 +373,10 @@ export class TestViewMedia extends ViewMedia implements EventViewMedia { protected _score: number | null = null; protected _tags: string[] | null = null; protected _where: string[] | null = null; + protected _severity: Severity | null = null; + protected _reviewed: boolean | null = null; + protected _description: string | null = null; + protected _favorite: boolean | null = null; constructor(options?: { id?: string | null; @@ -345,12 +388,16 @@ export class TestViewMedia extends ViewMedia implements EventViewMedia { inProgress?: boolean; contentID?: string; title?: string | null; + description?: string | null; thumbnail?: string | null; icon?: string | null; what?: string[] | null; score?: number | null; tags?: string[] | null; where?: string[] | null; + severity?: Severity | null; + reviewed?: boolean | null; + favorite?: boolean | null; }) { super(options?.mediaType ?? ViewMediaType.Clip, { ...(options?.cameraID !== null && @@ -363,12 +410,16 @@ export class TestViewMedia extends ViewMedia implements EventViewMedia { this._inProgress = options?.inProgress !== undefined ? options.inProgress : false; this._contentID = options?.contentID ?? null; this._title = options?.title !== undefined ? options.title : null; + this._description = options?.description !== undefined ? options.description : null; this._thumbnail = options?.thumbnail !== undefined ? options.thumbnail : null; this._icon = options?.icon !== undefined ? options.icon : null; this._what = options?.what !== undefined ? options.what : null; this._score = options?.score !== undefined ? options.score : null; this._tags = options?.tags !== undefined ? options.tags : null; this._where = options?.where !== undefined ? options.where : null; + this._severity = options?.severity !== undefined ? options.severity : null; + this._reviewed = options?.reviewed !== undefined ? options.reviewed : null; + this._favorite = options?.favorite !== undefined ? options.favorite : null; } public getIcon(): string | null { return this._icon; @@ -391,6 +442,9 @@ export class TestViewMedia extends ViewMedia implements EventViewMedia { public getTitle(): string | null { return this._title; } + public getDescription(): string | null { + return this._description; + } public getThumbnail(): string | null { return this._thumbnail; } @@ -410,6 +464,21 @@ export class TestViewMedia extends ViewMedia implements EventViewMedia { public isGroupableWith(_that: EventViewMedia): boolean { return false; } + public getSeverity(): Severity | null { + return this._severity; + } + public isReviewed(): boolean | null { + return this._reviewed; + } + public setReviewed(reviewed: boolean): void { + this._reviewed = reviewed; + } + public isFavorite(): boolean | null { + return this._favorite; + } + public setFavorite(favorite: boolean): void { + this._favorite = favorite; + } } export const ResizeObserverMock = vi.fn(() => ({ @@ -602,6 +671,7 @@ export const createCardAPI = (): CardController => { api.getMediaPlayerManager.mockReturnValue(mock()); api.getMessageManager.mockReturnValue(mock()); api.getMicrophoneManager.mockReturnValue(mock()); + api.getOverlayMessageManager.mockReturnValue(mock()); api.getQueryStringManager.mockReturnValue(mock()); api.getStatusBarItemManager.mockReturnValue(mock()); api.getStyleManager.mockReturnValue(mock()); @@ -718,3 +788,60 @@ export const createRichBrowseMedia = ( }, }; }; + +export const createEventQuery = ( + cameraID: string, + options?: Partial, +): EventQuery => ({ + source: QuerySource.Camera, + type: QueryType.Event, + cameraIDs: new Set([cameraID]), + ...options, +}); + +export const createReviewQuery = ( + cameraID: string, + options?: Partial, +): ReviewQuery => ({ + source: QuerySource.Camera, + type: QueryType.Review, + cameraIDs: new Set([cameraID]), + ...options, +}); + +export const createRecordingQuery = ( + cameraID: string, + options?: Partial, +): RecordingQuery => ({ + source: QuerySource.Camera, + type: QueryType.Recording, + cameraIDs: new Set([cameraID]), + ...options, +}); + +export const createFolderQuery = (folderId: string): FolderQuery => ({ + source: QuerySource.Folder, + folder: { id: folderId, type: 'ha', title: folderId }, + path: [{ ha: { id: 'Root' } }], +}); + +export const isEventQuery = (node: { + source: QuerySource; + type?: QueryType; +}): node is EventQuery => + node.source === QuerySource.Camera && node.type === QueryType.Event; + +export const isRecordingQuery = (node: { + source: QuerySource; + type?: QueryType; +}): node is RecordingQuery => + node.source === QuerySource.Camera && node.type === QueryType.Recording; + +export const isReviewQuery = (node: { + source: QuerySource; + type?: QueryType; +}): node is ReviewQuery => + node.source === QuerySource.Camera && node.type === QueryType.Review; + +export const isFolderQuery = (node: { source: QuerySource }): node is FolderQuery => + node.source === QuerySource.Folder; diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index df3308b1..adb97a80 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -16,6 +16,7 @@ import { createPTZDigitalAction, createPTZMultiAction, createSelectOptionAction, + createSetReviewAction, createViewAction, getActionConfigGivenAction, hasAction, @@ -317,6 +318,32 @@ describe('createEffectAction', () => { }); }); +describe('createSetReviewAction', () => { + it('should create set review action', () => { + expect(createSetReviewAction(true)).toEqual({ + action: 'fire-dom-event', + advanced_camera_card_action: 'set_review', + reviewed: true, + }); + }); + + it('should create set review action with false', () => { + expect(createSetReviewAction(false)).toEqual({ + action: 'fire-dom-event', + advanced_camera_card_action: 'set_review', + reviewed: false, + }); + }); + + it('should create set review action with undefined', () => { + expect(createSetReviewAction()).toEqual({ + action: 'fire-dom-event', + advanced_camera_card_action: 'set_review', + reviewed: undefined, + }); + }); +}); + describe('getActionConfigGivenAction', () => { const action = createViewAction('clips'); diff --git a/tests/utils/embla/carousel-controller.test.ts b/tests/utils/embla/carousel-controller.test.ts index 29abbe82..56dc5b45 100644 --- a/tests/utils/embla/carousel-controller.test.ts +++ b/tests/utils/embla/carousel-controller.test.ts @@ -143,7 +143,8 @@ describe('CarouselController', () => { carousel.selectSlide(11); - expect(getEmblaApi()?.scrollTo).toBeCalledWith(11, false); + // Should not call scrollTo or fire event because index is out of bounds + expect(getEmblaApi()?.scrollTo).not.toBeCalled(); expect(forceSelectListener).not.toBeCalled(); }); @@ -257,7 +258,7 @@ describe('CarouselController', () => { ); }); - it('should recreate carousel when children are added', () => { + it('should reinit carousel when children are added', () => { const children = createTestSlideNodes(); const root = createRoot(); const parent = createParent({ children: children }); @@ -269,13 +270,17 @@ describe('CarouselController', () => { expect(originalEmblaApi).toBeTruthy(); originalEmblaApi?.slideNodes.mockReturnValue(children); - parent.appendChild(document.createElement('div')); + const newChild = document.createElement('div'); + parent.appendChild(newChild); callMutationHandler(); - expect(originalEmblaApi?.destroy).toBeCalled(); - expect(getEmblaApi()).not.toBe(originalEmblaApi); + // Should call reInit instead of destroy/recreate + expect(originalEmblaApi?.reInit).toBeCalledWith({ + slides: [...children, newChild], + }); - expect(EmblaCarousel).toBeCalledTimes(2); + // Should still be same carousel instance (no new creation) + expect(EmblaCarousel).toBeCalledTimes(1); }); it('should not recreate carousel when children have not changed', () => { @@ -298,7 +303,7 @@ describe('CarouselController', () => { expect(EmblaCarousel).toBeCalledTimes(1); }); - it('should recreate carousel when children are added to slot', () => { + it('should reinit carousel when children are added to slot', () => { const children = createTestSlideNodes(); const slot = createSlot(); const host = createSlotHost({ slot: slot, children: children }); @@ -312,12 +317,16 @@ describe('CarouselController', () => { originalEmblaApi?.slideNodes.mockReturnValue(children); - host.appendChild(document.createElement('div')); + const newChild = document.createElement('div'); + host.appendChild(newChild); slot.dispatchEvent(new Event('slotchange')); - expect(originalEmblaApi?.destroy).toBeCalled(); - expect(getEmblaApi()).not.toBe(originalEmblaApi); + // Should call reInit instead of destroy/recreate + expect(originalEmblaApi?.reInit).toBeCalledWith({ + slides: [...children, newChild], + }); - expect(EmblaCarousel).toBeCalledTimes(2); + // Should still be same carousel instance (no new creation) + expect(EmblaCarousel).toBeCalledTimes(1); }); }); diff --git a/tests/utils/find-best-media-time-index.test.ts b/tests/utils/find-best-media-time-index.test.ts new file mode 100644 index 00000000..82383739 --- /dev/null +++ b/tests/utils/find-best-media-time-index.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest'; +import { findBestMediaTimeIndex } from '../../src/utils/find-best-media-time-index'; +import { ViewFolder } from '../../src/view/item'; +import { TestViewMedia } from '../test-utils'; + +describe('findBestMediaTimeIndex', () => { + it('should handle non-media items', () => { + const folder = new ViewFolder({ id: 'folder', type: 'ha', title: 'folder' }, []); + const media = new TestViewMedia({ + startTime: new Date('2024-01-01T10:00:00'), + endTime: new Date('2024-01-01T11:00:00'), + }); + + const index = findBestMediaTimeIndex( + [folder, media], + new Date('2024-01-01T10:30:00'), + ); + expect(index).toBe(1); + }); + + it('should find longest match', () => { + const media1 = new TestViewMedia({ + startTime: new Date('2024-01-01T10:00:00'), + endTime: new Date('2024-01-01T10:30:00'), + }); + const media2 = new TestViewMedia({ + startTime: new Date('2024-01-01T10:00:00'), + endTime: new Date('2024-01-01T11:00:00'), + }); + + const index = findBestMediaTimeIndex( + [media1, media2], + new Date('2024-01-01T10:15:00'), + ); + expect(index).toBe(1); + }); + + it('should favor specified camera', () => { + const media1 = new TestViewMedia({ + cameraID: 'camera1', + startTime: new Date('2024-01-01T10:00:00'), + endTime: new Date('2024-01-01T11:00:00'), + }); + const media2 = new TestViewMedia({ + cameraID: 'camera2', + startTime: new Date('2024-01-01T10:00:00'), + endTime: new Date('2024-01-01T10:30:00'), + }); + + // Even though media1 is longer, favor media2's camera. + const index = findBestMediaTimeIndex( + [media1, media2], + new Date('2024-01-01T10:15:00'), + 'camera2', + ); + expect(index).toBe(1); + }); + + it('should handle multiple matches from non-favored cameras', () => { + const media1 = new TestViewMedia({ + cameraID: 'camera1', + startTime: new Date('2024-01-01T10:00:00'), + endTime: new Date('2024-01-01T10:30:00'), + }); + const media2 = new TestViewMedia({ + cameraID: 'camera3', + startTime: new Date('2024-01-01T10:00:00'), + endTime: new Date('2024-01-01T11:00:00'), + }); + + const index = findBestMediaTimeIndex( + [media1, media2], + new Date('2024-01-01T10:15:00'), + 'camera2', // Favored camera not present + ); + expect(index).toBe(1); // Longest match wins + }); + + it('should handle missing start or end time', () => { + const media = new TestViewMedia({ + startTime: null, + endTime: null, + }); + vi.spyOn(media, 'includesTime').mockReturnValue(true); + + const index = findBestMediaTimeIndex([media], new Date()); + expect(index).toBeNull(); + }); + + it('should return null when no match', () => { + const media = new TestViewMedia({ + startTime: new Date('2024-01-01T10:00:00'), + endTime: new Date('2024-01-01T11:00:00'), + }); + const index = findBestMediaTimeIndex([media], new Date('2024-01-01T12:00:00')); + expect(index).toBeNull(); + }); + + it('should reject longer match if favored camera already matched', () => { + const media1 = new TestViewMedia({ + cameraID: 'favored', + startTime: new Date('2024-01-01T10:00:00'), + endTime: new Date('2024-01-01T10:30:00'), + }); + const media2 = new TestViewMedia({ + cameraID: 'not-favored', + startTime: new Date('2024-01-01T10:00:00'), + endTime: new Date('2024-01-01T11:00:00'), + }); + + // media2 is longer, but media1 is favored and already matches. + const index = findBestMediaTimeIndex( + [media1, media2], + new Date('2024-01-01T10:15:00'), + 'favored', + ); + expect(index).toBe(0); + }); +}); diff --git a/tests/utils/folder.test.ts b/tests/utils/folder.test.ts new file mode 100644 index 00000000..38ba65d6 --- /dev/null +++ b/tests/utils/folder.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { getFolderID } from '../../src/utils/folder'; + +describe('getFolderID', () => { + it('should get folder id from config', () => { + expect(getFolderID({ id: 'my-folder' })).toBe('my-folder'); + }); + + it('should get folder id from index when no id in config', () => { + expect(getFolderID({}, 5)).toBe('folder/5'); + }); + + it('should use default index 0 when index is missing', () => { + expect(getFolderID({})).toBe('folder/0'); + }); + + it('should handle null config', () => { + expect(getFolderID(null, 2)).toBe('folder/2'); + }); + + it('should handle undefined config', () => { + expect(getFolderID(undefined, 3)).toBe('folder/3'); + }); +}); diff --git a/tests/utils/live-provider.test.ts b/tests/utils/live-provider.test.ts new file mode 100644 index 00000000..e448df12 --- /dev/null +++ b/tests/utils/live-provider.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as go2rtcAudio from '../../src/camera-manager/utils/go2rtc/audio'; +import { + getResolvedLiveProvider, + liveProviderSupports2WayAudio, +} from '../../src/utils/live-provider'; +import { createCameraConfig, createHASS } from '../test-utils'; + +vi.mock('../../src/camera-manager/utils/go2rtc/audio'); + +describe('live-provider utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getResolvedLiveProvider', () => { + it('should resolve webrtc-card with entity', () => { + const config = createCameraConfig({ + live_provider: 'auto', + webrtc_card: { entity: 'camera.test' }, + }); + expect(getResolvedLiveProvider(config)).toBe('webrtc-card'); + }); + + it('should resolve webrtc-card with url', () => { + const config = createCameraConfig({ + live_provider: 'auto', + webrtc_card: { url: 'http://test' }, + }); + expect(getResolvedLiveProvider(config)).toBe('webrtc-card'); + }); + + it('should resolve ha', () => { + const config = createCameraConfig({ + live_provider: 'auto', + camera_entity: 'camera.test', + }); + expect(getResolvedLiveProvider(config)).toBe('ha'); + }); + + it('should resolve jsmpeg', () => { + const config = createCameraConfig({ + live_provider: 'auto', + frigate: { camera_name: 'test' }, + }); + expect(getResolvedLiveProvider(config)).toBe('jsmpeg'); + }); + + it('should resolve image by default for auto', () => { + const config = createCameraConfig({ + live_provider: 'auto', + }); + expect(getResolvedLiveProvider(config)).toBe('image'); + }); + + it('should return explicitly configured provider', () => { + const config = createCameraConfig({ + live_provider: 'go2rtc', + }); + expect(getResolvedLiveProvider(config)).toBe('go2rtc'); + }); + + it('should return image if config is undefined', () => { + expect(getResolvedLiveProvider(undefined)).toBe('image'); + }); + }); + + describe('liveProviderSupports2WayAudio', () => { + it('should return false if resolved provider is not go2rtc', async () => { + const config = createCameraConfig({ + live_provider: 'ha', + }); + const hass = createHASS(); + const result = await liveProviderSupports2WayAudio(hass, config); + expect(result).toBe(false); + }); + + it('should return result from go2rtcSupports2WayAudio', async () => { + const config = createCameraConfig({ + live_provider: 'go2rtc', + }); + const hass = createHASS(); + vi.mocked(go2rtcAudio.supports2WayAudio).mockResolvedValue(true); + + const result = await liveProviderSupports2WayAudio(hass, config); + expect(result).toBe(true); + expect(go2rtcAudio.supports2WayAudio).toHaveBeenCalledWith( + hass, + undefined, + undefined, + ); + }); + }); +}); diff --git a/tests/utils/media-actions.test.ts b/tests/utils/media-actions.test.ts new file mode 100644 index 00000000..b45aed77 --- /dev/null +++ b/tests/utils/media-actions.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { ViewItemManager } from '../../src/card-controller/view/item-manager'; +import { RemoveContextViewModifier } from '../../src/card-controller/view/modifiers/remove-context'; +import { ViewManagerEpoch } from '../../src/card-controller/view/types'; +import { ViewManager } from '../../src/card-controller/view/view-manager'; +import { + downloadMedia, + navigateToTimeline, + toggleFavorite, + toggleReviewed, +} from '../../src/utils/media-actions'; +import { ViewItem, ViewMediaType } from '../../src/view/item'; +import { QueryResults } from '../../src/view/query-results'; +import { View } from '../../src/view/view'; +import { TestViewMedia } from '../test-utils'; + +describe('MediaActions', () => { + describe('toggleReviewed', () => { + it('should return false if item is not review', async () => { + const item = new TestViewMedia({ mediaType: ViewMediaType.Clip }); + const options = { viewItemManager: mock() }; + + expect(await toggleReviewed(item, options)).toBe(false); + }); + + it('should return false if manager is missing', async () => { + const item = new TestViewMedia({ mediaType: ViewMediaType.Review }); + const options = {}; + + expect(await toggleReviewed(item, options)).toBe(false); + }); + + it('should toggle review status and update view', async () => { + const item = new TestViewMedia({ + mediaType: ViewMediaType.Review, + reviewed: false, + }); + const viewItemManager = mock(); + const viewManagerEpoch = mock(); + const viewManager = mock(); + const view = mock(); + const queryResults = mock(); + + viewManagerEpoch.manager = viewManager; + viewManager.getView.mockReturnValue(view); + view.queryResults = queryResults; + queryResults.clone.mockReturnValue(queryResults); + queryResults.removeItem.mockReturnValue(queryResults); + + const options = { viewItemManager, viewManagerEpoch }; + + expect(await toggleReviewed(item, options)).toBe(true); + expect(viewItemManager.reviewMedia).toHaveBeenCalledWith(item, true); + expect(item.isReviewed()).toBe(true); + expect(viewManager.setViewByParameters).toHaveBeenCalled(); + }); + + it('should not update view if queryResults is missing', async () => { + const item = new TestViewMedia({ + mediaType: ViewMediaType.Review, + reviewed: false, + }); + const viewItemManager = mock(); + const viewManagerEpoch = mock(); + const viewManager = mock(); + const view = mock(); + + viewManagerEpoch.manager = viewManager; + viewManager.getView.mockReturnValue(view); + view.queryResults = null; + + const options = { viewItemManager, viewManagerEpoch }; + + expect(await toggleReviewed(item, options)).toBe(true); + expect(viewManager.setViewByParameters).not.toHaveBeenCalled(); + }); + + it('should handle manager error', async () => { + const item = new TestViewMedia({ + mediaType: ViewMediaType.Review, + reviewed: false, + }); + const viewItemManager = mock(); + const error = new Error('fail'); + viewItemManager.reviewMedia.mockRejectedValue(error); + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const options = { viewItemManager }; + + expect(await toggleReviewed(item, options)).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith(error.message); + + consoleSpy.mockRestore(); + }); + }); + + describe('toggleFavorite', () => { + it('should return false if item is not media', async () => { + const options = { viewItemManager: mock() }; + + expect(await toggleFavorite(null as unknown as ViewItem, options)).toBe(false); + }); + + it('should return false if manager is missing', async () => { + const item = new TestViewMedia({ mediaType: ViewMediaType.Clip }); + const options = {}; + + expect(await toggleFavorite(item, options)).toBe(false); + }); + + it('should toggle favorite status', async () => { + const item = new TestViewMedia({ + mediaType: ViewMediaType.Clip, + favorite: false, + }); + const viewItemManager = mock(); + const options = { viewItemManager }; + + expect(await toggleFavorite(item, options)).toBe(true); + expect(viewItemManager.favorite).toHaveBeenCalledWith(item, true); + }); + + it('should handle manager error', async () => { + const item = new TestViewMedia({ + mediaType: ViewMediaType.Clip, + favorite: false, + }); + const viewItemManager = mock(); + const error = new Error('fail'); + viewItemManager.favorite.mockRejectedValue(error); + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const options = { viewItemManager }; + + expect(await toggleFavorite(item, options)).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith(error.message); + + consoleSpy.mockRestore(); + }); + }); + + describe('downloadMedia', () => { + it('should return false if manager is missing', async () => { + const item = new TestViewMedia({ mediaType: ViewMediaType.Clip }); + const options = {}; + + expect(await downloadMedia(item, options)).toBe(false); + }); + + it('should download media', async () => { + const item = new TestViewMedia({ mediaType: ViewMediaType.Clip }); + const viewItemManager = mock(); + const options = { viewItemManager }; + + expect(await downloadMedia(item, options)).toBe(true); + expect(viewItemManager.download).toHaveBeenCalledWith(item); + }); + + it('should handle manager error', async () => { + const item = new TestViewMedia({ mediaType: ViewMediaType.Clip }); + const viewItemManager = mock(); + const error = new Error('fail'); + viewItemManager.download.mockRejectedValue(error); + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const options = { viewItemManager }; + + expect(await downloadMedia(item, options)).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith(error.message); + + consoleSpy.mockRestore(); + }); + }); + + describe('navigateToTimeline', () => { + it('should return early if epoch is missing', () => { + const item = new TestViewMedia({ mediaType: ViewMediaType.Clip }); + const options = {}; + + expect(navigateToTimeline(item, options)).toBeUndefined(); + }); + + it('should navigate to timeline with correct parameters', () => { + const item = new TestViewMedia({ mediaType: ViewMediaType.Clip }); + const viewManagerEpoch = mock(); + const viewManager = mock(); + const view = mock(); + const queryResults = mock(); + + viewManagerEpoch.manager = viewManager; + viewManager.getView.mockReturnValue(view); + view.queryResults = queryResults; + queryResults.clone.mockReturnValue(queryResults); + + // Make selectResultIfFound call the predicate to gain coverage. + queryResults.selectResultIfFound.mockImplementation((predicate) => { + predicate(item); + return queryResults; + }); + + const options = { viewManagerEpoch }; + + navigateToTimeline(item, options); + + expect(viewManager.setViewByParameters).toHaveBeenCalledWith({ + params: { + view: 'timeline', + queryResults: queryResults, + }, + modifiers: [expect.any(RemoveContextViewModifier)], + }); + }); + + it('should handle missing view/queryResults during navigation', () => { + const item = new TestViewMedia({ mediaType: ViewMediaType.Clip }); + const viewManagerEpoch = mock(); + const viewManager = mock(); + + viewManagerEpoch.manager = viewManager; + viewManager.getView.mockReturnValue(null); + + const options = { viewManagerEpoch }; + + navigateToTimeline(item, options); + + expect(viewManager.setViewByParameters).toHaveBeenCalledWith({ + params: { + view: 'timeline', + queryResults: undefined, + }, + modifiers: [new RemoveContextViewModifier(['timeline'])], + }); + }); + }); +}); diff --git a/tests/utils/media-info.test.ts b/tests/utils/media-info.test.ts index ca4553e9..7f51c709 100644 --- a/tests/utils/media-info.test.ts +++ b/tests/utils/media-info.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { AdvancedCameraCardMediaPlayer, MediaLoadedCapabilities } from '../../src/types'; +import { MediaLoadedCapabilities, MediaPlayer } from '../../src/types'; import { createMediaLoadedInfo, dispatchExistingMediaLoadedInfoAsEvent, @@ -14,7 +14,7 @@ import { import { createMediaLoadedInfo as createTestMediaLoadedInfo } from '../test-utils.js'; const options = { - player: mock(), + player: mock(), capabilities: mock(), }; @@ -86,7 +86,7 @@ describe('createMediaLoadedInfo', () => { // @vitest-environment jsdom describe('dispatchMediaLoadedEvent', () => { const options = { - player: mock(), + player: mock(), capabilities: mock(), }; diff --git a/tests/utils/overlay-message.test.ts b/tests/utils/overlay-message.test.ts new file mode 100644 index 00000000..4a80e802 --- /dev/null +++ b/tests/utils/overlay-message.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + dispatchDismissOverlayMessageEvent, + dispatchShowOverlayMessageEvent, +} from '../../src/utils/overlay-message'; + +// @vitest-environment jsdom +describe('overlay-message utils', () => { + it('should dispatch show overlay message event', () => { + const element = document.createElement('div'); + const message = { text: 'test' }; + const handler = vi.fn(); + element.addEventListener('advanced-camera-card:overlay-message:show', handler); + + dispatchShowOverlayMessageEvent(element, message); + + expect(handler).toHaveBeenCalled(); + const event = handler.mock.calls[0][0]; + expect(event.detail).toBe(message); + }); + + it('should dispatch dismiss overlay message event', () => { + const element = document.createElement('div'); + const handler = vi.fn(); + element.addEventListener('advanced-camera-card:overlay-message:dismiss', handler); + + dispatchDismissOverlayMessageEvent(element); + + expect(handler).toHaveBeenCalled(); + }); +}); diff --git a/tests/utils/task.test.ts b/tests/utils/task.test.ts new file mode 100644 index 00000000..7939730c --- /dev/null +++ b/tests/utils/task.test.ts @@ -0,0 +1,89 @@ +import { Task } from '@lit-labs/task'; +import { html, render, TemplateResult } from 'lit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { renderProgressIndicator } from '../../src/components/progress-indicator'; +import { renderTask } from '../../src/utils/task'; + +vi.mock('../../src/components/progress-indicator'); + +const getRenderedContent = (template: TemplateResult): string => { + const container = document.createElement('div'); + render(template, container); + return container.textContent || ''; +}; + +// @vitest-environment jsdom +describe('task utilities', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should render initial state', () => { + const task = mock>(); + task.render.mockImplementation((renderers) => renderers.initial?.()); + vi.mocked(renderProgressIndicator).mockReturnValue(html`progress`); + + const result = renderTask(task, (r) => html`${r}`); + expect(getRenderedContent(result)).toContain('progress'); + }); + + it('should render pending state', () => { + const task = mock>(); + task.render.mockImplementation((renderers) => renderers.pending?.()); + vi.mocked(renderProgressIndicator).mockReturnValue(html`progress`); + + const result = renderTask(task, (r) => html`${r}`); + expect(getRenderedContent(result)).toContain('progress'); + }); + + it('should render error state', () => { + const task = mock>(); + task.render.mockImplementation((renderers) => + renderers.error?.(new Error('test error')), + ); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const errorFunc = vi.fn().mockReturnValue(html`error`); + + const result = renderTask(task, (r) => html`${r}`, { errorFunc }); + + expect(errorFunc).toHaveBeenCalledWith(new Error('test error')); + expect(consoleSpy).toHaveBeenCalled(); + expect(getRenderedContent(result)).toContain('error'); + }); + + it('should render complete state', () => { + const task = mock>(); + task.render.mockImplementation((renderers) => renderers.complete?.('result')); + + const result = renderTask(task, (r) => html`complete: ${r}`); + expect(getRenderedContent(result)).toContain('complete: result'); + }); + + it('should use custom inProgressFunc', () => { + const task = mock>(); + task.render.mockImplementation((renderers) => renderers.initial?.()); + const inProgressFunc = () => html`custom progress`; + + const result = renderTask(task, (r) => html`${r}`, { inProgressFunc }); + expect(getRenderedContent(result)).toContain('custom progress'); + }); + + it('should use cardWideConfig', () => { + const task = mock>(); + task.render.mockImplementation((renderers) => renderers.initial?.()); + const cardWideConfig = {}; + + vi.mocked(renderProgressIndicator).mockReturnValue(html`progress`); + + renderTask(task, (r) => html`${r}`, { cardWideConfig }); + + expect(renderProgressIndicator).toHaveBeenCalledWith({ + cardWideConfig, + }); + }); +}); diff --git a/tests/utils/thumbnail.test.ts b/tests/utils/thumbnail.test.ts new file mode 100644 index 00000000..3178b2eb --- /dev/null +++ b/tests/utils/thumbnail.test.ts @@ -0,0 +1,233 @@ +import { Task } from '@lit-labs/task'; +import { ReactiveControllerHost } from '@lit/reactive-element'; +import { afterEach, assert, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { createFetchThumbnailTask } from '../../src/utils/thumbnail'; +import { createHASS, flushPromises } from '../test-utils'; + +vi.mock('@lit-labs/task'); + +describe('thumbnail utilities', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('should handle absolute thumbnail URL', async () => { + const host = mock(); + const hass = createHASS(); + const thumbnailURL = 'http://example.com/thumb.jpg'; + + createFetchThumbnailTask( + host, + () => hass, + () => thumbnailURL, + ); + const call = vi.mocked(Task).mock.calls[0]; + assert(call); + + const options = call[1]; + const result = await options.task([true, thumbnailURL]); + + expect(result).toBe(thumbnailURL); + expect(hass.fetchWithAuth).not.toHaveBeenCalled(); + }); + + it('should handle data thumbnail URL', async () => { + const host = mock(); + const hass = createHASS(); + const thumbnailURL = 'data:image/jpeg;base64,...'; + + createFetchThumbnailTask( + host, + () => hass, + () => thumbnailURL, + ); + const call = vi.mocked(Task).mock.calls[0]; + assert(call); + + const options = call[1]; + const result = await options.task([true, thumbnailURL]); + + expect(result).toBe(thumbnailURL); + }); + + it('should fetch relative thumbnail URL', async () => { + const host = mock(); + const hass = createHASS(); + const thumbnailURL = '/api/frigate/thumb.jpg'; + const dataURL = 'data:image/jpeg;base64,encoded'; + + const mockResponse = mock(); + Object.defineProperty(mockResponse, 'ok', { value: true }); + + const mockBlob = new Blob(['test'], { type: 'image/jpeg' }); + mockResponse.blob.mockResolvedValue(mockBlob); + vi.mocked(hass.fetchWithAuth).mockResolvedValue(mockResponse); + + const mockFileReader = mock({ + result: dataURL, + }); + vi.stubGlobal( + 'FileReader', + vi.fn(() => mockFileReader), + ); + + createFetchThumbnailTask( + host, + () => hass, + () => thumbnailURL, + ); + + const call = vi.mocked(Task).mock.calls[0]; + assert(call); + + const options = call[1]; + const runPromise = options.task([true, thumbnailURL]); + + await flushPromises(); + mockFileReader.onload?.(mock>()); + + const result = await runPromise; + expect(result).toBe(dataURL); + expect(hass.fetchWithAuth).toHaveBeenCalledWith(thumbnailURL); + }); + + it('should handle fetch failure', async () => { + const host = mock(); + const hass = createHASS(); + const thumbnailURL = '/api/thumb.jpg'; + + const mockResponse = mock(); + Object.defineProperty(mockResponse, 'ok', { value: false }); + Object.defineProperty(mockResponse, 'statusText', { value: 'Not Found' }); + vi.mocked(hass.fetchWithAuth).mockResolvedValue(mockResponse); + + createFetchThumbnailTask( + host, + () => hass, + () => thumbnailURL, + ); + const call = vi.mocked(Task).mock.calls[0]; + assert(call); + + const options = call[1]; + await expect(options.task([true, thumbnailURL])).rejects.toThrow('Not Found'); + }); + + it('should handle reader error', async () => { + const host = mock(); + const hass = createHASS(); + const thumbnailURL = '/api/thumb.jpg'; + + const mockResponse = mock(); + Object.defineProperty(mockResponse, 'ok', { value: true }); + mockResponse.blob.mockResolvedValue(new Blob()); + vi.mocked(hass.fetchWithAuth).mockResolvedValue(mockResponse); + + const mockFileReader = mock(); + vi.stubGlobal( + 'FileReader', + vi.fn(() => mockFileReader), + ); + + createFetchThumbnailTask( + host, + () => hass, + () => thumbnailURL, + ); + const call = vi.mocked(Task).mock.calls[0]; + assert(call); + + const options = call[1]; + const runPromise = options.task([true, thumbnailURL]); + + await flushPromises(); + mockFileReader.onerror?.( + new Error('Reader error') as unknown as ProgressEvent, + ); + + await expect(runPromise).rejects.toThrow('Reader error'); + }); + + it('should handle non-string reader result', async () => { + const host = mock(); + const hass = createHASS(); + const thumbnailURL = '/api/thumb.jpg'; + + const mockResponse = mock(); + Object.defineProperty(mockResponse, 'ok', { value: true }); + mockResponse.blob.mockResolvedValue(new Blob()); + vi.mocked(hass.fetchWithAuth).mockResolvedValue(mockResponse); + + const mockFileReader = mock({ + result: null as unknown as string, // Non-string result + }); + vi.stubGlobal( + 'FileReader', + vi.fn(() => mockFileReader), + ); + + createFetchThumbnailTask( + host, + () => hass, + () => thumbnailURL, + ); + const call = vi.mocked(Task).mock.calls[0]; + assert(call); + + const options = call[1]; + const runPromise = options.task([true, thumbnailURL]); + + await flushPromises(); + mockFileReader.onload?.(mock>()); + + const result = await runPromise; + expect(result).toBeNull(); + }); + + it('should return null if no hass or no url', async () => { + const host = mock(); + createFetchThumbnailTask( + host, + () => undefined, + () => undefined, + ); + const call = vi.mocked(Task).mock.calls[0]; + assert(call); + + const options = call[1]; + const result = await options.task([false, undefined]); + expect(result).toBeNull(); + }); + + it('should have correct task arguments', () => { + const host = mock(); + const hass = createHASS(); + const thumbnailURL = 'http://example.com/thumb.jpg'; + + createFetchThumbnailTask( + host, + () => hass, + () => thumbnailURL, + ); + const call = vi.mocked(Task).mock.calls[0]; + assert(call && call[1].args); + + const args = call[1].args(); + expect(args).toEqual([true, thumbnailURL]); + + vi.mocked(Task).mockClear(); + + createFetchThumbnailTask( + host, + () => undefined, + () => undefined, + ); + const call2 = vi.mocked(Task).mock.calls[0]; + assert(call2 && call2[1].args); + + const args2 = call2[1].args(); + expect(args2).toEqual([false, undefined]); + }); +}); diff --git a/tests/view/item-classifier.test.ts b/tests/view/item-classifier.test.ts index 2d0bf99d..97c97c8c 100644 --- a/tests/view/item-classifier.test.ts +++ b/tests/view/item-classifier.test.ts @@ -6,11 +6,11 @@ import { createFolder, TestViewMedia } from '../test-utils'; describe('ViewItemClassifier', () => { it('isMedia', () => { expect(ViewItemClassifier.isMedia(new TestViewMedia())).toBe(true); - expect(ViewItemClassifier.isMedia(new ViewFolder(createFolder()))).toBe(false); + expect(ViewItemClassifier.isMedia(new ViewFolder(createFolder(), []))).toBe(false); }); it('isFolder', () => { - expect(ViewItemClassifier.isFolder(new ViewFolder(createFolder()))).toBe(true); + expect(ViewItemClassifier.isFolder(new ViewFolder(createFolder(), []))).toBe(true); expect(ViewItemClassifier.isFolder(new TestViewMedia())).toBe(false); }); @@ -73,4 +73,40 @@ describe('ViewItemClassifier', () => { ).toBe(expectedResult); }); }); + + describe('supportsTimeline', () => { + it('should return false when item is not a media item with a start time', () => { + expect(ViewItemClassifier.supportsTimeline(null)).toBe(false); + expect(ViewItemClassifier.supportsTimeline(undefined)).toBe(false); + expect( + ViewItemClassifier.supportsTimeline(new ViewFolder(createFolder(), [])), + ).toBe(false); + expect( + ViewItemClassifier.supportsTimeline( + new TestViewMedia({ mediaType: ViewMediaType.Clip, startTime: null }), + ), + ).toBe(false); + }); + + it('should return true when item is a media item with a start time', () => { + expect( + ViewItemClassifier.supportsTimeline( + new TestViewMedia({ mediaType: ViewMediaType.Clip, startTime: new Date() }), + ), + ).toBe(true); + expect( + ViewItemClassifier.supportsTimeline( + new TestViewMedia({ mediaType: ViewMediaType.Review, startTime: new Date() }), + ), + ).toBe(true); + expect( + ViewItemClassifier.supportsTimeline( + new TestViewMedia({ + mediaType: ViewMediaType.Recording, + startTime: new Date(), + }), + ), + ).toBe(true); + }); + }); }); diff --git a/tests/view/item.test.ts b/tests/view/item.test.ts index ab2786aa..0b0cbc6d 100644 --- a/tests/view/item.test.ts +++ b/tests/view/item.test.ts @@ -35,13 +35,28 @@ describe('ViewMedia', () => { expect(media.inProgress()).toBeNull(); expect(media.getContentID()).toBeNull(); expect(media.getTitle()).toBeNull(); + expect(media.getDescription()).toBeNull(); expect(media.getThumbnail()).toBeNull(); expect(media.getTitle()).toBeNull(); expect(media.includesTime(new Date())).toBeFalsy(); expect(media.getWhere()).toBeNull(); expect(media.setFavorite(true)).toBeUndefined(); expect(media.isFavorite()).toBeNull(); + expect(media.isReviewed()).toBeNull(); expect(media.getIcon()).toBeNull(); + expect(media.getSeverity()).toBeNull(); + }); + + it('should clone', () => { + const media = new ViewMedia(ViewMediaType.Clip, { + cameraID: 'camera', + }); + + const clone = media.clone(); + + expect(clone).not.toBe(media); + expect(clone).toBeInstanceOf(ViewMedia); + expect(clone.getCameraID()).toBe('camera'); }); it('should correctly determine if a media item includes a time', () => { @@ -70,7 +85,7 @@ describe('ViewMedia', () => { describe('ViewFolder', () => { it('should construct', () => { const folder = createFolder(); - const item = new ViewFolder(folder, { + const item = new ViewFolder(folder, [], { icon: 'icon', id: 'id', title: 'title', @@ -80,8 +95,21 @@ describe('ViewFolder', () => { expect(item.getFolder()).toEqual(folder); expect(item.getID()).toBe('id'); expect(item.getTitle()).toBe('title'); + expect(item.getDescription()).toBeNull(); expect(item.getThumbnail()).toBe('thumbnail'); expect(item.getIcon()).toBe('icon'); expect(item.isFavorite()).toBeNull(); + expect(item.getSeverity()).toBeNull(); + }); + + it('should clone', () => { + const folder = createFolder(); + const item = new ViewFolder(folder, []); + + const clone = item.clone(); + + expect(clone).not.toBe(item); + expect(clone).toBeInstanceOf(ViewFolder); + expect(clone.getFolder()).toEqual(folder); }); }); diff --git a/tests/view/query-classifier.test.ts b/tests/view/query-classifier.test.ts deleted file mode 100644 index 376df961..00000000 --- a/tests/view/query-classifier.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { QueryType } from '../../src/camera-manager/types'; -import { - EventMediaQuery, - FolderViewQuery, - RecordingMediaQuery, -} from '../../src/view/query'; -import { QueryClassifier } from '../../src/view/query-classifier'; -import { createFolder } from '../test-utils'; - -describe('QueryClassifier', () => { - it('isEventQuery', () => { - expect(QueryClassifier.isEventQuery(new EventMediaQuery())).toBeTruthy(); - expect(QueryClassifier.isEventQuery(new RecordingMediaQuery())).toBeFalsy(); - }); - - it('isRecordingQuery', () => { - expect(QueryClassifier.isRecordingQuery(new RecordingMediaQuery())).toBeTruthy(); - expect(QueryClassifier.isRecordingQuery(new EventMediaQuery())).toBeFalsy(); - }); - - it('getQueryType', () => { - expect(QueryClassifier.getQueryType(new EventMediaQuery())).toBe('event'); - expect(QueryClassifier.getQueryType(new FolderViewQuery())).toBe('folder'); - expect(QueryClassifier.getQueryType(new RecordingMediaQuery())).toBe('recording'); - expect(QueryClassifier.getQueryType()).toBeNull(); - }); - - it('isClipsQuery', () => { - expect( - QueryClassifier.isClipsQuery( - new EventMediaQuery([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, - ]), - ), - ).toBeTruthy(); - expect( - QueryClassifier.isClipsQuery( - new EventMediaQuery([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: false }, - ]), - ), - ).toBeFalsy(); - expect( - QueryClassifier.isClipsQuery( - new EventMediaQuery([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, - { type: QueryType.Event, cameraIDs: new Set(['camera']) }, - ]), - ), - ).toBeFalsy(); - }); - - it('isSnapshotQuery', () => { - expect( - QueryClassifier.isSnapshotQuery( - new EventMediaQuery([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true }, - ]), - ), - ).toBeTruthy(); - expect( - QueryClassifier.isSnapshotQuery( - new EventMediaQuery([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true }, - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: false }, - ]), - ), - ).toBeFalsy(); - expect( - QueryClassifier.isSnapshotQuery( - new EventMediaQuery([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true }, - { type: QueryType.Event, cameraIDs: new Set(['camera']) }, - ]), - ), - ).toBeFalsy(); - }); - - it('isFolderQuery', () => { - expect( - QueryClassifier.isFolderQuery( - new EventMediaQuery([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true }, - ]), - ), - ).toBeFalsy(); - expect( - QueryClassifier.isFolderQuery( - new RecordingMediaQuery([ - { type: QueryType.Recording, cameraIDs: new Set(['camera']) }, - ]), - ), - ).toBeFalsy(); - expect( - QueryClassifier.isFolderQuery(new FolderViewQuery({ folder: createFolder() })), - ).toBeTruthy(); - }); - - it('getMediaType', () => { - expect( - QueryClassifier.getMediaType( - new EventMediaQuery([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true }, - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true }, - ]), - ), - ).toBe('snapshots'); - - expect( - QueryClassifier.getMediaType( - new EventMediaQuery([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, - ]), - ), - ).toBe('clips'); - - expect( - QueryClassifier.getMediaType( - new RecordingMediaQuery([ - { type: QueryType.Recording, cameraIDs: new Set(['camera']) }, - { type: QueryType.Recording, cameraIDs: new Set(['camera']) }, - ]), - ), - ).toBe('recordings'); - - expect( - QueryClassifier.getMediaType( - new EventMediaQuery([ - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true }, - { type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: false }, - ]), - ), - ).toBeNull(); - }); -}); diff --git a/tests/view/query-results.test.ts b/tests/view/query-results.test.ts index f45193af..d2a40954 100644 --- a/tests/view/query-results.test.ts +++ b/tests/view/query-results.test.ts @@ -1,7 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; import { ViewFolder, ViewItem } from '../../src/view/item'; import { QueryResults } from '../../src/view/query-results'; -import { createFolder, generateViewMediaArray } from '../test-utils'; +import { createFolder, generateViewMediaArray, TestViewMedia } from '../test-utils'; describe('dispatchViewContextChangeEvent', () => { beforeEach(() => { @@ -281,7 +281,7 @@ describe('dispatchViewContextChangeEvent', () => { }); it('should keep selected folder in main slice but not camera slices', () => { - const folder = new ViewFolder(createFolder()); + const folder = new ViewFolder(createFolder(), []); const results = new QueryResults({ results: [ folder, @@ -295,4 +295,148 @@ describe('dispatchViewContextChangeEvent', () => { expect(results.getSelectedResult()).toBe(folder); expect(results.getSelectedResult('camera.office')).not.toBe(folder); }); + + describe('removeItem', () => { + it('should remove item from results', () => { + const testResults = generateViewMediaArray(); + const results = new QueryResults({ results: testResults }); + + const itemToRemove = testResults[50]; + expect(results.getResults()?.includes(itemToRemove)).toBeTruthy(); + expect(results.getResultsCount()).toBe(200); + + results.removeItem(itemToRemove); + + expect(results.getResults()?.includes(itemToRemove)).toBeFalsy(); + expect(results.getResultsCount()).toBe(199); + }); + + it('should not remove item that is not in results', () => { + const testResults = generateViewMediaArray(); + const results = new QueryResults({ results: testResults }); + + const outsideItem = generateViewMediaArray({ cameraIDs: ['other'] })[0]; + const countBefore = results.getResultsCount(); + + results.removeItem(outsideItem); + + expect(results.getResultsCount()).toBe(countBefore); + }); + + it('should adjust selection when removing selected item', () => { + const testResults = generateViewMediaArray(); + const results = new QueryResults({ results: testResults, selectedIndex: 50 }); + + const itemToRemove = testResults[50]; + results.removeItem(itemToRemove); + + // After removing selected item at index 50, Math.min(50, 199-1) = 50 + // which is still valid in the new 199-element array + expect(results.getSelectedIndex()).toBe(50); + }); + + it('should adjust selection when removing item before selected', () => { + const testResults = generateViewMediaArray(); + const results = new QueryResults({ results: testResults, selectedIndex: 50 }); + + const itemToRemove = testResults[10]; + results.removeItem(itemToRemove); + + // Selection should decrement + expect(results.getSelectedIndex()).toBe(49); + }); + + it('should not change selection when removing item after selected', () => { + const testResults = generateViewMediaArray(); + const results = new QueryResults({ results: testResults, selectedIndex: 50 }); + + const itemToRemove = testResults[100]; + results.removeItem(itemToRemove); + + // Selection should stay the same + expect(results.getSelectedIndex()).toBe(50); + }); + + it('should set selection to null when removing last remaining selected item', () => { + const testResults = generateViewMediaArray({ cameraIDs: ['camera'], count: 1 }); + const results = new QueryResults({ results: testResults, selectedIndex: 0 }); + + results.removeItem(testResults[0]); + + // Selection should be null when no items remain + expect(results.getSelectedIndex()).toBeNull(); + }); + + it('should handle removing folder item (non-media)', () => { + const folder = new ViewFolder(createFolder(), []); + const testResults = generateViewMediaArray(); + const results = new QueryResults({ results: [folder, ...testResults] }); + + const countBefore = results.getResultsCount(); + results.removeItem(folder); + + expect(results.getResultsCount()).toBe(countBefore - 1); + }); + }); + + describe('replaceItem', () => { + it('should replace item in slice', () => { + const testResults = generateViewMediaArray(); + const results = new QueryResults({ results: testResults }); + const slice = results.getSlice('office'); + + assert(slice); + + const oldItem = slice.getResults()[42]; + const newItem = oldItem.clone(); + + expect(slice?.replaceItem(oldItem, newItem)).toBeTruthy(); + expect(slice?.getResults()[42]).toBe(newItem); + }); + + it('should fail to replace item not in camera slice', () => { + const results = new QueryResults({ results: generateViewMediaArray() }); + const slice = results.getSlice('office'); + + assert(slice); + + const foreignItem = new TestViewMedia({ + cameraID: 'other', + }); + const newItem = foreignItem.clone(); + + expect(slice.replaceItem(foreignItem, newItem)).toBeFalsy(); + }); + + it('should replace item in main slice', () => { + const testResults = generateViewMediaArray(); + const results = new QueryResults({ results: testResults }); + + const oldItem = testResults[42]; + const newItem = oldItem.clone(); + + expect(results.replaceItem(oldItem, newItem)).toBe(results); + expect(results.getResults()?.[42]).toBe(newItem); + expect(results.getSlice('kitchen')?.getResults().includes(newItem)).toBeTruthy(); + expect(results.getSlice('kitchen')?.getResults().includes(oldItem)).toBeFalsy(); + }); + + it('should fail to replace item not in main slice', () => { + const results = new QueryResults({ results: generateViewMediaArray() }); + + const outsideItem = generateViewMediaArray({ cameraIDs: ['other'] })[0]; + const newItem = outsideItem.clone(); + + expect(results.replaceItem(outsideItem, newItem)).toBe(results); + }); + + it('should replace folder in main slice', () => { + const folder = new ViewFolder(createFolder(), []); + const results = new QueryResults({ results: [folder] }); + + const newFolder = folder.clone(); + expect(results.replaceItem(folder, newFolder)).toBe(results); + expect(results.getResults()?.[0]).toBe(newFolder); + }); + }); }); diff --git a/tests/view/query.test.ts b/tests/view/query.test.ts deleted file mode 100644 index a2c8a3e7..00000000 --- a/tests/view/query.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - EventQuery, - PartialEventQuery, - PartialRecordingQuery, - QueryType, - RecordingQuery, -} from '../../src/camera-manager/types'; -import { FolderQuery } from '../../src/card-controller/folders/types'; -import { setify } from '../../src/utils/basic'; -import { - EventMediaQuery, - FolderViewQuery, - RecordingMediaQuery, -} from '../../src/view/query'; -import { createFolder } from '../test-utils'; - -describe('EventMediaQuery', () => { - const createRawEventQueries = ( - cameraIDs: string | Set, - query?: PartialEventQuery, - ): EventQuery[] => { - return [ - { - type: QueryType.Event, - cameraIDs: setify(cameraIDs), - ...query, - }, - ]; - }; - - it('should construct', () => { - const rawQueries = createRawEventQueries('office'); - const query = new EventMediaQuery(rawQueries); - expect(query.getQuery()).toBe(rawQueries); - }); - - it('should set', () => { - const rawQueries = createRawEventQueries('office'); - const query = new EventMediaQuery(rawQueries); - - const newRawQueries = createRawEventQueries('kitchen'); - query.setQuery(newRawQueries); - expect(query.getQuery()).toBe(newRawQueries); - }); - - it('should determine if queries exist for CameraIDs', () => { - const rawQueries = createRawEventQueries(new Set(['office', 'kitchen'])); - const query = new EventMediaQuery(rawQueries); - - expect(query.hasQueriesForCameraIDs(new Set(['office']))).toBeTruthy(); - expect(query.hasQueriesForCameraIDs(new Set(['office', 'kitchen']))).toBeTruthy(); - expect(query.hasQueriesForCameraIDs(new Set(['office', 'front_door']))).toBeFalsy(); - }); - - it('should convert to clips querys', () => { - const rawQueries = createRawEventQueries('office', { hasSnapshot: true }); - const query = new EventMediaQuery(rawQueries); - - expect(query.convertToClipsQueries().getQuery()).toEqual([ - { - type: QueryType.Event, - cameraIDs: new Set(['office']), - hasClip: true, - }, - ]); - }); - - it('should convert when queries are null', () => { - const query = new EventMediaQuery(); - expect(query.convertToClipsQueries().getQuery()).toBeNull(); - }); - - describe('should determine equality', () => { - it('should return true when query is equal', () => { - const rawQueries_1 = createRawEventQueries('office', { hasSnapshot: true }); - const query_1 = new EventMediaQuery(rawQueries_1); - - const rawQueries_2 = createRawEventQueries('office', { hasSnapshot: true }); - const query_2 = new EventMediaQuery(rawQueries_2); - - expect(query_1.isEqual(query_2)).toBeTruthy(); - }); - - it('should return false when query is not equal', () => { - const rawQueries_1 = createRawEventQueries('office', { hasSnapshot: true }); - const query_1 = new EventMediaQuery(rawQueries_1); - - const rawQueries_2 = createRawEventQueries('office', { hasSnapshot: false }); - const query_2 = new EventMediaQuery(rawQueries_2); - - expect(query_1.isEqual(query_2)).toBeFalsy(); - }); - }); - - it('should clone', () => { - const rawQueries = createRawEventQueries('office', { hasSnapshot: true }); - const query = new EventMediaQuery(rawQueries); - expect(query.clone().getQuery()).toEqual(query.getQuery()); - }); - - it('should get camera IDs when queries are null', () => { - expect(new EventMediaQuery().getQueryCameraIDs()).toBeNull(); - }); - - it('should get camera IDs', () => { - const cameraIDs = ['office', 'kitchen']; - const query = new EventMediaQuery(createRawEventQueries(new Set(cameraIDs))); - expect(query.getQueryCameraIDs()).toEqual(new Set(cameraIDs)); - }); - - it('should set camera IDs when queries are null', () => { - expect( - new EventMediaQuery().setQueryCameraIDs(new Set(['office'])).getQueryCameraIDs(), - ).toBeNull(); - }); - - it('should set camera IDs', () => { - const query = new EventMediaQuery(createRawEventQueries('sitting_room')); - const newCameraIDs = new Set(['office', 'kitchen']); - expect(query.setQueryCameraIDs(newCameraIDs).getQueryCameraIDs()).toEqual( - newCameraIDs, - ); - }); - - describe('should determine when queries are a superset', () => { - it('should return true with itself', () => { - const query_1 = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['office']), - start: new Date('2025-03-07T00:00:00.000Z'), - end: new Date('2025-03-08T00:00:00.000Z'), - }, - ]); - expect(query_1.isSupersetOf(query_1)).toBeTruthy(); - }); - - it('should return true with an identical but shorter query', () => { - const query_1 = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['office']), - start: new Date('2025-03-07T00:00:00.000Z'), - end: new Date('2025-03-08T00:00:00.000Z'), - }, - ]); - const query_2 = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['office']), - start: new Date('2025-03-07T01:00:00.000Z'), - end: new Date('2025-03-07T23:00:00.000Z'), - }, - ]); - expect(query_1.isSupersetOf(query_2)).toBeTruthy(); - }); - - it('should return false with an identical but longer query', () => { - const query_1 = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['office']), - start: new Date('2025-03-07T00:00:00.000Z'), - end: new Date('2025-03-08T00:00:00.000Z'), - }, - ]); - const query_2 = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['office']), - start: new Date('2025-03-06T00:00:00.000Z'), - end: new Date('2025-03-08T00:00:00.000Z'), - }, - ]); - expect(query_1.isSupersetOf(query_2)).toBeFalsy(); - }); - - it('should return false with a non-matching query', () => { - const query_1 = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['office']), - start: new Date('2025-03-07T00:00:00.000Z'), - end: new Date('2025-03-08T00:00:00.000Z'), - }, - ]); - const query_2 = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['DIFFERENT']), - start: new Date('2025-03-07T00:00:00.000Z'), - end: new Date('2025-03-08T00:00:00.000Z'), - }, - ]); - expect(query_1.isSupersetOf(query_2)).toBeFalsy(); - }); - - it('should return true with a matching query where the source has multiple', () => { - const query_1 = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['office']), - start: new Date('2025-03-07T00:00:00.000Z'), - end: new Date('2025-03-08T00:00:00.000Z'), - }, - { - type: QueryType.Event, - cameraIDs: new Set(['kitchen']), - start: new Date('2025-03-07T00:00:00.000Z'), - end: new Date('2025-03-08T00:00:00.000Z'), - }, - ]); - const query_2 = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['office']), - start: new Date('2025-03-07T00:00:00.000Z'), - end: new Date('2025-03-08T00:00:00.000Z'), - }, - ]); - expect(query_1.isSupersetOf(query_2)).toBeTruthy(); - }); - - it('should return false with a matching query where the target has multiple', () => { - const query_1 = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['office']), - start: new Date('2025-03-07T00:00:00.000Z'), - end: new Date('2025-03-08T00:00:00.000Z'), - }, - ]); - const query_2 = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['office']), - start: new Date('2025-03-07T00:00:00.000Z'), - end: new Date('2025-03-08T00:00:00.000Z'), - }, - { - type: QueryType.Event, - cameraIDs: new Set(['kitchen']), - start: new Date('2025-03-07T00:00:00.000Z'), - end: new Date('2025-03-08T00:00:00.000Z'), - }, - ]); - expect(query_1.isSupersetOf(query_2)).toBeFalsy(); - }); - - it('should return true when queries do not have start or end', () => { - const query_1 = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['office']), - }, - ]); - const query_2 = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['office']), - }, - ]); - expect(query_1.isSupersetOf(query_2)).toBeTruthy(); - }); - - it('should return true when target has no queries', () => { - const query_1 = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['office']), - }, - ]); - const query_2 = new EventMediaQuery(); - expect(query_1.isSupersetOf(query_2)).toBeTruthy(); - }); - - it('should return false when source has no queries', () => { - const query_1 = new EventMediaQuery(); - const query_2 = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['office']), - }, - ]); - expect(query_1.isSupersetOf(query_2)).toBeFalsy(); - }); - }); -}); - -describe('RecordingMediaQuery', () => { - const createRawRecordingQueries = ( - cameraIDs: string | Set, - query?: PartialRecordingQuery, - ): RecordingQuery[] => { - return [ - { - type: QueryType.Recording, - cameraIDs: setify(cameraIDs), - ...query, - }, - ]; - }; - - it('should construct', () => { - const rawQueries = createRawRecordingQueries('office'); - const query = new RecordingMediaQuery(rawQueries); - expect(query.getQuery()).toBe(rawQueries); - }); -}); - -describe('FolderViewQuery', () => { - it('should construct', () => { - const rawQuery: FolderQuery = { - folder: createFolder(), - path: [{}], - }; - - const query = new FolderViewQuery(rawQuery); - expect(query.getQuery()).toBe(rawQuery); - }); -}); diff --git a/tests/view/unified-query-builder.test.ts b/tests/view/unified-query-builder.test.ts new file mode 100644 index 00000000..cba40dc4 --- /dev/null +++ b/tests/view/unified-query-builder.test.ts @@ -0,0 +1,1126 @@ +import { assert, describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CameraManager } from '../../src/camera-manager/manager'; +import { CameraManagerStore } from '../../src/camera-manager/store'; +import { QueryType } from '../../src/camera-manager/types'; +import { FoldersManager } from '../../src/card-controller/folders/manager'; +import { + FolderPathComponent, + FolderQuery, +} from '../../src/card-controller/folders/types'; +import { FolderConfig } from '../../src/config/schema/folders'; +import { QuerySource } from '../../src/query-source'; +import { + MediaTypeSpec, + UnifiedQueryBuilder, +} from '../../src/view/unified-query-builder'; +import { + createCameraConfig, + createCapabilities, + createFolder, + isRecordingQuery, + isReviewQuery, +} from '../test-utils'; + +// Helper to create FolderQuery for tests +const createFolderQueryParams = ( + folder: FolderConfig, + path: [FolderPathComponent, ...FolderPathComponent[]], +): FolderQuery => ({ + source: QuerySource.Folder, + folder, + path, +}); + +const createMocks = () => { + const cameraManager = mock(); + const foldersManager = mock(); + const store = mock(); + cameraManager.getStore.mockReturnValue(store); + cameraManager.getDefaultQueryParameters.mockReturnValue({}); + return { cameraManager, foldersManager, store }; +}; + +describe('MediaTypeSpec', () => { + describe('factory helpers', () => { + it('clips() returns correct spec', () => { + expect(MediaTypeSpec.clips()).toEqual({ + mediaType: 'events', + eventsSubtype: 'clips', + }); + }); + + it('snapshots() returns correct spec', () => { + expect(MediaTypeSpec.snapshots()).toEqual({ + mediaType: 'events', + eventsSubtype: 'snapshots', + }); + }); + + it('recordings() returns correct spec', () => { + expect(MediaTypeSpec.recordings()).toEqual({ mediaType: 'recordings' }); + }); + + it('reviews() returns correct spec', () => { + expect(MediaTypeSpec.reviews()).toEqual({ mediaType: 'reviews' }); + }); + }); +}); + +describe('UnifiedQueryBuilder', () => { + describe('buildClipsQuery', () => { + it('should return query with no nodes when empty camera IDs', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildClipsQuery(new Set()); + expect(query).toBeNull(); + }); + + it('should build clips query with hasClip=true', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildClipsQuery(new Set(['camera1'])); + + assert(query); + expect(query.getNodes()).toHaveLength(1); + expect(query.getNodes()[0]).toMatchObject({ + source: QuerySource.Camera, + type: QueryType.Event, + hasClip: true, + cameraIDs: new Set(['camera1']), + }); + expect(query.getNodes()[0]).not.toHaveProperty('hasSnapshot'); + }); + + it('should apply all MediaQueryBuildOptions', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const start = new Date('2024-01-01'); + const end = new Date('2024-01-02'); + + const query = builder.buildClipsQuery(new Set(['camera1']), { + start, + end, + limit: 25, + }); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + start, + end, + limit: 25, + }); + }); + }); + + describe('buildSnapshotsQuery', () => { + it('should build snapshots query with hasSnapshot=true', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildSnapshotsQuery(new Set(['camera1'])); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + hasSnapshot: true, + }); + expect(query.getNodes()[0]).not.toHaveProperty('hasClip'); + }); + }); + + describe('buildEventsQuery', () => { + it('should build events query without clip/snapshot filter', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildEventsQuery(new Set(['camera1'])); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Event }); + expect(query.getNodes()[0]).not.toHaveProperty('hasClip'); + expect(query.getNodes()[0]).not.toHaveProperty('hasSnapshot'); + }); + }); + + describe('buildRecordingsQuery', () => { + it('should return null if no camera IDs provided', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + expect(builder.buildRecordingsQuery(new Set())).toBeNull(); + }); + + it('should build recordings query', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildRecordingsQuery(new Set(['camera1'])); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Recording, + cameraIDs: new Set(['camera1']), + }); + }); + + it('should apply options to recordings query', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildRecordingsQuery(new Set(['camera1']), { + limit: 10, + }); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Recording, + limit: 10, + }); + }); + }); + + describe('buildReviewsQuery', () => { + it('should return query with no nodes when empty camera IDs', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildReviewsQuery(new Set()); + expect(query).toBeNull(); + }); + + it('should build reviews query', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildReviewsQuery(new Set(['camera1'])); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Review }); + }); + + it('should apply reviewed option', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildReviewsQuery(new Set(['camera1']), { + reviewed: true, + }); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Review, + reviewed: true, + }); + }); + + it('should apply reviewed=false option', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildReviewsQuery(new Set(['camera1']), { + reviewed: false, + }); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Review, + reviewed: false, + }); + }); + }); + + describe('getAllMediaCapableCameraIDs', () => { + it('should return cameras with any media capability', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera1', 'camera2'])); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const result = builder.getAllMediaCapableCameraIDs(); + + expect(result).toEqual(new Set(['camera1', 'camera2'])); + expect(store.getCameraIDsWithCapability).toHaveBeenCalledWith({ + anyCapabilities: ['clips', 'snapshots', 'recordings', 'reviews'], + }); + }); + }); + + describe('buildFilterQuery', () => { + it('should use default cameras when null provided', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildFilterQuery(null, new Set(['clips'])); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + cameraIDs: new Set(['camera.office']), + }); + }); + + it('should use default media types when null provided', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildFilterQuery(new Set(['camera.office']), null); + + assert(query); + + // clips, snapshots, recordings, reviews + expect(query.getNodes()).toHaveLength(4); + }); + + it('should build clips filter query', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildFilterQuery( + new Set(['camera.office']), + new Set(['clips']), + ); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + hasClip: true, + }); + }); + + it('should build snapshots filter query', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildFilterQuery( + new Set(['camera.office']), + new Set(['snapshots']), + ); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + hasSnapshot: true, + }); + }); + + it('should build recordings filter query', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildFilterQuery( + new Set(['camera.office']), + new Set(['recordings']), + ); + + assert(query); + assert(isRecordingQuery(query.getNodes()[0])); + }); + + it('should build reviews filter query', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildFilterQuery( + new Set(['camera.office']), + new Set(['reviews']), + ); + + assert(query); + assert(isReviewQuery(query.getNodes()[0])); + }); + + it('should apply all filter options', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const start = new Date('2024-01-01'); + const end = new Date('2024-01-02'); + const tags = new Set(['tag1']); + const what = new Set(['person']); + const where = new Set(['zone1']); + + const query = builder.buildFilterQuery( + new Set(['camera.office']), + new Set(['clips']), + { + start, + end, + limit: 5, + favorite: true, + tags, + what, + where, + reviewed: false, + }, + ); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + start, + end, + limit: 5, + favorite: true, + tags, + what, + where, + reviewed: false, + }); + }); + + it('should apply reviewed option to reviews query', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + + const query = builder.buildFilterQuery( + new Set(['camera.office']), + new Set(['reviews']), + { + reviewed: true, + }, + ); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Review, + reviewed: true, + }); + }); + + it('should return null when no cameras available', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDsWithCapability.mockReturnValue(new Set()); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildFilterQuery(new Set(), null); + + expect(query).toBeNull(); + }); + + describe('filter application', () => { + it('should apply filters to query nodes', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + + const query = builder.buildFilterQuery( + new Set(['camera.office']), + new Set(['clips']), + { + favorite: true, + tags: new Set(['tag']), + }, + ); + + assert(query); + const nodes = query.getNodes(); + expect(nodes).toHaveLength(1); + expect(nodes[0].favorite).toBe(true); + expect(nodes[0].tags).toEqual(new Set(['tag'])); + }); + + it('should apply reviewed filter to reviews', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + + const query = builder.buildFilterQuery( + new Set(['camera.office']), + new Set(['reviews']), + { + reviewed: true, + }, + ); + + assert(query); + const nodes = query.getNodes(); + expect(nodes).toHaveLength(1); + expect(nodes[0].reviewed).toBe(true); + }); + + it('should apply what/where filters', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + + const query = builder.buildFilterQuery( + new Set(['camera.office']), + new Set(['clips']), + { + what: new Set(['person']), + where: new Set(['zone']), + }, + ); + + assert(query); + const nodes = query.getNodes(); + expect(nodes).toHaveLength(1); + expect(nodes[0].what).toEqual(new Set(['person'])); + expect(nodes[0].where).toEqual(new Set(['zone'])); + }); + }); + }); + + describe('buildFolderQueryWithPath', () => { + it('should build folder query', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const folder = createFolder({ id: 'folder1', title: 'Test' }); + const path: [FolderPathComponent] = [{ ha: { id: 'Root' } }]; + + const query = builder.buildFolderQueryWithPath(folder, path); + + const nodes = query.getNodes(); + expect(nodes).toHaveLength(1); + expect(nodes[0]).toMatchObject({ + source: QuerySource.Folder, + folder, + path, + }); + }); + + it('should apply limit option', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const folder = createFolder({ id: 'folder1', title: 'Test' }); + const path: [FolderPathComponent] = [{ ha: { id: 'Root' } }]; + + const query = builder.buildFolderQueryWithPath(folder, path, { + limit: 10, + }); + + expect(query.getNodes()[0]).toMatchObject({ + source: QuerySource.Folder, + limit: 10, + }); + }); + + it('should not include limit when not provided', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const folder = createFolder({ id: 'folder1', title: 'Test' }); + const path: [FolderPathComponent] = [{ ha: { id: 'Root' } }]; + + const query = builder.buildFolderQueryWithPath(folder, path); + + expect(query.getNodes()[0]).toMatchObject({ + source: QuerySource.Folder, + }); + expect(query.getNodes()[0]).not.toHaveProperty('limit'); + }); + }); + + describe('buildDefaultFolderQuery', () => { + it('should build query from folder manager params', () => { + const { cameraManager, foldersManager } = createMocks(); + const folder = createFolder({ id: 'folder1', title: 'Test' }); + const path: [FolderPathComponent] = [{ ha: { id: 'Root' } }]; + + foldersManager.getFolder.mockReturnValue(folder); + foldersManager.getDefaultQueryParameters.mockReturnValue( + createFolderQueryParams(folder, path), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultFolderQuery('folder1'); + + assert(query); + expect(query.hasNodes()).toBe(true); + expect(foldersManager.getFolder).toHaveBeenCalledWith('folder1'); + }); + + it('should return empty query when folder not found', () => { + const { cameraManager, foldersManager } = createMocks(); + foldersManager.getFolder.mockReturnValue(null); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultFolderQuery('nonexistent'); + expect(query).toBeNull(); + }); + + it('should return empty query when no default params', () => { + const { cameraManager, foldersManager } = createMocks(); + const folder = createFolder({ id: 'folder1', title: 'Test' }); + foldersManager.getFolder.mockReturnValue(folder); + foldersManager.getDefaultQueryParameters.mockReturnValue(null); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultFolderQuery('folder1'); + expect(query).toBeNull(); + }); + + it('should apply limit option', () => { + const { cameraManager, foldersManager } = createMocks(); + const folder = createFolder({ id: 'folder1', title: 'Test' }); + const path: [FolderPathComponent] = [{ ha: { id: 'Root' } }]; + + foldersManager.getFolder.mockReturnValue(folder); + foldersManager.getDefaultQueryParameters.mockReturnValue( + createFolderQueryParams(folder, path), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultFolderQuery('folder1', { limit: 20 }); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + source: QuerySource.Folder, + limit: 20, + }); + }); + + it('should use default folder when no ID provided', () => { + const { cameraManager, foldersManager } = createMocks(); + foldersManager.getFolder.mockReturnValue(null); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + builder.buildDefaultFolderQuery(); + + expect(foldersManager.getFolder).toHaveBeenCalledWith(undefined); + }); + }); + + describe('buildDefaultCameraQuery', () => { + it('should return null when no cameras available', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set()); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + expect(query).toBeNull(); + }); + + it('should use all cameras when no ID provided', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office', 'cam2'])); + cameraManager.getCameraCapabilities.mockReturnValue(null); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + builder.buildDefaultCameraQuery(); + + expect(store.getCameraIDs).toHaveBeenCalled(); + }); + + it('should use dependent cameras when ID provided', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getAllDependentCameras.mockReturnValue(new Set(['camera.office', 'dep1'])); + cameraManager.getCameraCapabilities.mockReturnValue(null); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + builder.buildDefaultCameraQuery('camera.office'); + + expect(store.getAllDependentCameras).toHaveBeenCalledWith('camera.office'); + }); + + it('should build reviews query for camera with reviews capability', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'auto' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ reviews: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + assert(isReviewQuery(query.getNodes()[0])); + }); + + it('should build clips query for camera with clips capability', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'auto' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ clips: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + hasClip: true, + }); + }); + + it('should build snapshots query for camera with only snapshots', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'auto' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ snapshots: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + hasSnapshot: true, + }); + }); + + it('should build recordings query for camera with only recordings', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'auto' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ recordings: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + assert(isRecordingQuery(query.getNodes()[0])); + }); + + it('should respect explicit recordings media type config', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'recordings' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ recordings: true, reviews: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + assert(isRecordingQuery(query.getNodes()[0])); + }); + + it('should respect explicit reviews media type config', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'reviews' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ reviews: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + assert(isReviewQuery(query.getNodes()[0])); + }); + + it('should handle explicit events type with clips', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'events', events_type: 'clips' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ clips: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + hasClip: true, + }); + }); + + it('should handle explicit events type with snapshots', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'events', events_type: 'snapshots' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ snapshots: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + hasSnapshot: true, + }); + }); + + it('should handle events_type=all with both capabilities', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'events', events_type: 'all' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ clips: true, snapshots: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Event }); + // Both capabilities present with 'all' means no specific filter + expect(query.getNodes()[0]).not.toHaveProperty('hasClip'); + expect(query.getNodes()[0]).not.toHaveProperty('hasSnapshot'); + }); + + it('should handle events_type=all with only clips', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'events', events_type: 'all' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ clips: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + hasClip: true, + }); + }); + + it('should handle events_type=all with only snapshots', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'events', events_type: 'all' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ snapshots: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + hasSnapshot: true, + }); + }); + + it('should apply limit option', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'recordings' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ recordings: true }), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(undefined, { limit: 15 }); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Recording, + limit: 15, + }); + }); + + it('should return null when camera has no capabilities', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + cameraManager.getCameraCapabilities.mockReturnValue(null); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + expect(query).toBeNull(); + }); + + it('should return null for auto mode when no matching capabilities', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'auto' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue(createCapabilities()); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + expect(query).toBeNull(); + }); + + it('should build folder query for folder media type', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'folder', folders: ['folder1'] } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue(createCapabilities()); + + const folder = createFolder({ id: 'folder1', title: 'Test Folder' }); + const path: [FolderPathComponent] = [{ ha: { id: 'Root' } }]; + + foldersManager.getFolder.mockReturnValue(folder); + foldersManager.getDefaultQueryParameters.mockReturnValue( + createFolderQueryParams(folder, path), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + source: QuerySource.Folder, + folder: expect.objectContaining({ id: 'folder1' }), + }); + }); + + it('should build default folder query for folder media type without specific folders', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'folder' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue(createCapabilities()); + + const folder = createFolder({ id: 'default-folder', title: 'Default' }); + const path: [FolderPathComponent] = [{ ha: { id: 'Root' } }]; + + // Mock getting the default folder (id undefined) + foldersManager.getFolder.calledWith(undefined).mockReturnValue(folder); + foldersManager.getDefaultQueryParameters.mockReturnValue( + createFolderQueryParams(folder, path), + ); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + source: QuerySource.Folder, + folder: expect.objectContaining({ id: 'default-folder' }), + }); + }); + + it('should return null when events type specified but no clips/snapshots capability', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'events', events_type: 'clips' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue(createCapabilities()); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + expect(query).toBeNull(); + }); + + it('should return null when recordings type specified but no recordings capability', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'recordings' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue(createCapabilities()); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + expect(query).toBeNull(); + }); + + it('should return null when reviews type specified but no reviews capability', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getCameraConfig.mockReturnValue( + createCameraConfig({ media: { type: 'reviews' } }), + ); + + cameraManager.getCameraCapabilities.mockReturnValue(createCapabilities()); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildDefaultCameraQuery(); + + expect(query).toBeNull(); + }); + }); + + describe('buildMediaQuery', () => { + it('should build clips query', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildCameraMediaQuery(MediaTypeSpec.clips()); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + hasClip: true, + }); + }); + + it('should build snapshots query', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildCameraMediaQuery(MediaTypeSpec.snapshots()); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + hasSnapshot: true, + }); + }); + + it('should build recordings query', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildCameraMediaQuery(MediaTypeSpec.recordings()); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Recording }); + }); + + it('should build reviews query', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildCameraMediaQuery(MediaTypeSpec.reviews()); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Review }); + }); + + it('should use dependent cameras when cameraID provided', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getAllDependentCameras.mockReturnValue(new Set(['camera.office', 'dep1'])); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildCameraMediaQuery(MediaTypeSpec.clips(), { + cameraID: 'camera.office', + }); + + assert(query); + expect(store.getAllDependentCameras).toHaveBeenCalledWith( + 'camera.office', + 'clips', + ); + }); + + it('should apply limit option', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildCameraMediaQuery(MediaTypeSpec.recordings(), { + limit: 30, + }); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Recording, + limit: 30, + }); + }); + + it('should return null when no capable cameras', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDsWithCapability.mockReturnValue(new Set()); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildCameraMediaQuery(MediaTypeSpec.clips()); + + expect(query).toBeNull(); + }); + + it('should return null for folder media type', () => { + const { cameraManager, foldersManager } = createMocks(); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildCameraMediaQuery({ mediaType: 'folder' }); + + expect(query).toBeNull(); + }); + + it('should handle events without subtype', () => { + const { cameraManager, foldersManager, store } = createMocks(); + store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildCameraMediaQuery({ mediaType: 'events' }); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Event }); + expect(query.getNodes()[0]).not.toHaveProperty('hasClip'); + expect(query.getNodes()[0]).not.toHaveProperty('hasSnapshot'); + + expect(store.getCameraIDsWithCapability).toHaveBeenCalledWith({ + anyCapabilities: ['clips', 'snapshots'], + }); + }); + }); + + describe('default parameters merging', () => { + it('should merge what/where from multiple cameras', () => { + const { cameraManager, foldersManager } = createMocks(); + cameraManager.getDefaultQueryParameters.mockImplementation((id) => { + if (id === 'camera.office') { + return { what: new Set(['person']) }; + } + if (id === 'cam2') { + return { what: new Set(['car']), where: new Set(['driveway']) }; + } + return {}; + }); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildClipsQuery(new Set(['camera.office', 'cam2'])); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ + type: QueryType.Event, + what: new Set(['person', 'car']), + where: new Set(['driveway']), + }); + }); + + it('should not include what/where when empty', () => { + const { cameraManager, foldersManager } = createMocks(); + + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildClipsQuery(new Set(['camera.office'])); + + assert(query); + expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Event }); + + expect(query.getNodes()[0]).not.toHaveProperty('what'); + expect(query.getNodes()[0]).not.toHaveProperty('where'); + }); + }); +}); diff --git a/tests/view/unified-query-runner.test.ts b/tests/view/unified-query-runner.test.ts new file mode 100644 index 00000000..93e165ca --- /dev/null +++ b/tests/view/unified-query-runner.test.ts @@ -0,0 +1,224 @@ +import { assert, describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CameraManager } from '../../src/camera-manager/manager'; +import { FoldersManager } from '../../src/card-controller/folders/manager'; +import { ConditionStateManagerReadonlyInterface } from '../../src/conditions/types'; +import { QuerySource } from '../../src/query-source'; +import { ViewMedia } from '../../src/view/item'; +import { UnifiedQuery } from '../../src/view/unified-query'; +import { UnifiedQueryRunner } from '../../src/view/unified-query-runner'; +import { createEventQuery, createFolderQuery } from '../test-utils'; + +describe('UnifiedQueryRunner', () => { + describe('execute', () => { + it('should aggregate results from camera and folder sources', async () => { + const cameraManager = mock(); + const foldersManager = mock(); + const conditionStateManager = mock(); + + const runner = new UnifiedQueryRunner( + cameraManager, + foldersManager, + conditionStateManager, + ); + + const cameraItem = mock(); + const folderItem = mock(); + + cameraManager.executeMediaQueries.mockResolvedValue([cameraItem]); + foldersManager.expandFolder.mockResolvedValue([folderItem]); + + const query = new UnifiedQuery(); + query.addNode(createEventQuery('camera1')); + query.addNode(createFolderQuery('f1')); + + const results = await runner.execute(query); + + expect(results).toHaveLength(2); + expect(results).toContain(cameraItem); + expect(results).toContain(folderItem); + + expect(cameraManager.executeMediaQueries).toHaveBeenCalledWith( + [expect.objectContaining({ source: QuerySource.Camera })], + expect.anything(), + ); + expect(foldersManager.expandFolder).toHaveBeenCalledWith( + expect.objectContaining({ source: QuerySource.Folder }), + undefined, + expect.objectContaining({ useCache: undefined }), + ); + }); + + it('should handle empty query gracefully', async () => { + const cameraManager = mock(); + const foldersManager = mock(); + const runner = new UnifiedQueryRunner( + cameraManager, + foldersManager, + mock(), + ); + + const results = await runner.execute(new UnifiedQuery()); + expect(results).toHaveLength(0); + expect(cameraManager.executeMediaQueries).not.toHaveBeenCalled(); + expect(foldersManager.expandFolder).not.toHaveBeenCalled(); + }); + + it('should handle null returns from managers', async () => { + const cameraManager = mock(); + const foldersManager = mock(); + const runner = new UnifiedQueryRunner( + cameraManager, + foldersManager, + mock(), + ); + + cameraManager.executeMediaQueries.mockResolvedValue(null); + foldersManager.expandFolder.mockResolvedValue(null); + + const query = new UnifiedQuery(); + query.addNode(createEventQuery('camera1')); + query.addNode(createFolderQuery('f1')); + + const results = await runner.execute(query); + + expect(results).toHaveLength(0); + }); + + it('should pass options to managers', async () => { + const cameraManager = mock(); + const runner = new UnifiedQueryRunner( + cameraManager, + mock(), + mock(), + ); + + const query = new UnifiedQuery(); + query.addNode(createEventQuery('camera1')); + + await runner.execute(query, { useCache: false }); + + expect(cameraManager.executeMediaQueries).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ useCache: false }), + ); + }); + }); + + describe('areResultsFresh', () => { + it('should return true if managers report fresh', () => { + const cameraManager = mock(); + const foldersManager = mock(); + cameraManager.areMediaQueriesResultsFresh.mockReturnValue(true); + foldersManager.areResultsFresh.mockReturnValue(true); + + const runner = new UnifiedQueryRunner( + cameraManager, + foldersManager, + mock(), + ); + + const query = new UnifiedQuery(); + query.addNode(createEventQuery('camera1')); + query.addNode(createFolderQuery('f1')); + + expect(runner.areResultsFresh(new Date(), query)).toBe(true); + }); + + it('should return false if any manager reports stale', () => { + const cameraManager = mock(); + cameraManager.areMediaQueriesResultsFresh.mockReturnValue(false); + + const runner = new UnifiedQueryRunner( + cameraManager, + mock(), + mock(), + ); + + const query = new UnifiedQuery([createEventQuery('camera1')]); + expect(runner.areResultsFresh(new Date(), query)).toBe(false); + }); + + it('should return false if folder manager reports stale', () => { + const foldersManager = mock(); + foldersManager.areResultsFresh.mockReturnValue(false); + + const runner = new UnifiedQueryRunner( + mock(), + foldersManager, + mock(), + ); + + const query = new UnifiedQuery([createFolderQuery('f1')]); + expect(runner.areResultsFresh(new Date(), query)).toBe(false); + }); + }); + + describe('extend', () => { + it('should extend media queries and preserve folder queries', async () => { + const cameraManager = mock(); + const runner = new UnifiedQueryRunner( + cameraManager, + mock(), + mock(), + ); + + const existingItem = mock(); + const newItem = mock(); + const extendedMediaMatch = createEventQuery('camera1'); + + cameraManager.extendMediaQueries.mockResolvedValue({ + queries: [extendedMediaMatch], + results: [existingItem, newItem], + }); + + const query = new UnifiedQuery(); + query.addNode(createEventQuery('camera1')); + query.addNode(createFolderQuery('f1')); + + const result = await runner.extend(query, [existingItem], 'later'); + + assert(result); + expect(result.results).toHaveLength(2); + expect(result.query.getNodeCount()).toBe(2); + expect(result.query.getMediaQueries()).toHaveLength(1); + expect(result.query.getFolderQueries()).toHaveLength(1); + + expect(cameraManager.extendMediaQueries).toHaveBeenCalledWith( + [expect.objectContaining({ source: QuerySource.Camera })], + [existingItem], + 'later', + expect.anything(), + ); + }); + + it('should return null for query with no media nodes', async () => { + const runner = new UnifiedQueryRunner( + mock(), + mock(), + mock(), + ); + + const query = new UnifiedQuery([createFolderQuery('f1')]); + const result = await runner.extend(query, [], 'earlier'); + + expect(result).toBeNull(); + }); + + it('should return null if camera manager fails to extend', async () => { + const cameraManager = mock(); + cameraManager.extendMediaQueries.mockResolvedValue(null); + + const runner = new UnifiedQueryRunner( + cameraManager, + mock(), + mock(), + ); + + const query = new UnifiedQuery([createEventQuery('camera1')]); + const result = await runner.extend(query, [], 'earlier'); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/tests/view/unified-query-transformer.test.ts b/tests/view/unified-query-transformer.test.ts new file mode 100644 index 00000000..c404591b --- /dev/null +++ b/tests/view/unified-query-transformer.test.ts @@ -0,0 +1,145 @@ +import { assert, describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CameraManager } from '../../src/camera-manager/manager'; +import { CameraManagerStore } from '../../src/camera-manager/store'; +import { FoldersManager } from '../../src/card-controller/folders/manager'; +import { UnifiedQueryBuilder } from '../../src/view/unified-query-builder'; +import { UnifiedQueryTransformer } from '../../src/view/unified-query-transformer'; +import { + createCapabilities, + createFolder, + isEventQuery, + isFolderQuery, + isRecordingQuery, +} from '../test-utils'; + +const createMocks = () => { + const cameraManager = mock(); + const foldersManager = mock(); + const store = mock(); + + cameraManager.getStore.mockReturnValue(store); + store.getCameraIDs.mockReturnValue(new Set(['camera.office'])); + store.getAllDependentCameras.mockImplementation((id) => new Set([id])); + cameraManager.getCameraCapabilities.mockReturnValue( + createCapabilities({ clips: true, snapshots: true, recordings: true }), + ); + + return { cameraManager, foldersManager, store }; +}; + +describe('UnifiedQueryTransformer', () => { + describe('stripTimeRange', () => { + it('should remove start and end from camera queries', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildClipsQuery(new Set(['camera.office']), { + start: new Date('2024-01-01'), + end: new Date('2024-01-02'), + limit: 10, + }); + assert(query); + + const stripped = UnifiedQueryTransformer.stripTimeRange(query); + const node = stripped.getNodes()[0]; + assert(isEventQuery(node)); + + expect(node).not.toHaveProperty('start'); + expect(node).not.toHaveProperty('end'); + expect(node.limit).toBe(10); // Other props preserved + }); + + it('should not affect folder queries', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const folder = createFolder({ id: 'f1', title: 'Test' }); + const query = builder.buildFolderQueryWithPath(folder, [{ ha: { id: 'Root' } }]); + + const stripped = UnifiedQueryTransformer.stripTimeRange(query); + const node = stripped.getNodes()[0]; + assert(isFolderQuery(node)); + expect(node.folder.id).toBe('f1'); + }); + }); + + describe('rebuildQuery', () => { + it('should apply new options to camera queries', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildClipsQuery(new Set(['camera.office'])); + assert(query); + + const start = new Date('2024-06-01'); + const end = new Date('2024-06-02'); + + const rebuilt = UnifiedQueryTransformer.rebuildQuery(query, { + start, + end, + limit: 100, + }); + + const node = rebuilt.getNodes()[0]; + assert(isEventQuery(node)); + expect(node.start).toEqual(start); + expect(node.end).toEqual(end); + expect(node.limit).toBe(100); + }); + + it('should not affect folder queries', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const folder = createFolder({ id: 'f1', title: 'Test' }); + const query = builder.buildFolderQueryWithPath(folder, [{ ha: { id: 'Root' } }]); + + const rebuilt = UnifiedQueryTransformer.rebuildQuery(query, { + start: new Date(), + end: new Date(), + }); + + const node = rebuilt.getNodes()[0]; + assert(isFolderQuery(node)); + expect(node).not.toHaveProperty('start'); + expect(node).not.toHaveProperty('end'); + }); + }); + + describe('convertToClips', () => { + it('should convert event query to clips', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildSnapshotsQuery(new Set(['camera.office'])); + assert(query); + + const converted = UnifiedQueryTransformer.convertToClips(query); + const node = converted.getNodes()[0]; + assert(isEventQuery(node)); + + expect(node.hasClip).toBe(true); + expect(node.hasSnapshot).toBeUndefined(); + }); + + it('should not affect recording queries', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const query = builder.buildRecordingsQuery(new Set(['camera.office'])); + assert(query); + + const converted = UnifiedQueryTransformer.convertToClips(query); + const node = converted.getNodes()[0]; + assert(isRecordingQuery(node)); + expect(node).not.toHaveProperty('hasClip'); + }); + + it('should not affect folder queries', () => { + const { cameraManager, foldersManager } = createMocks(); + const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); + const folder = createFolder({ id: 'f1', title: 'Test' }); + const query = builder.buildFolderQueryWithPath(folder, [{ ha: { id: 'Root' } }]); + + const converted = UnifiedQueryTransformer.convertToClips(query); + const node = converted.getNodes()[0]; + assert(isFolderQuery(node)); + expect(node.folder.id).toBe('f1'); + }); + }); +}); diff --git a/tests/view/unified-query.test.ts b/tests/view/unified-query.test.ts new file mode 100644 index 00000000..e8ebb5d2 --- /dev/null +++ b/tests/view/unified-query.test.ts @@ -0,0 +1,334 @@ +import { assert, describe, expect, it } from 'vitest'; +import { QueryType } from '../../src/camera-manager/types'; +import { QuerySource } from '../../src/query-source'; +import { UnifiedQuery } from '../../src/view/unified-query'; +import { + createEventQuery, + createFolderQuery, + createRecordingQuery, + createReviewQuery, + isEventQuery, + isFolderQuery, +} from '../test-utils'; + +describe('UnifiedQuery', () => { + describe('Node Management', () => { + it('should construct empty', () => { + const query = new UnifiedQuery(); + expect(query.hasNodes()).toBe(false); + }); + + it('should construct with initial nodes', () => { + const nodes = [createEventQuery('front')]; + const query = new UnifiedQuery(nodes); + expect(query.hasNodes()).toBe(true); + }); + + it('should add node', () => { + const query = new UnifiedQuery(); + query.addNode(createEventQuery('front')); + expect(query.getMediaQueries()).toHaveLength(1); + }); + + it('should allow multiple nodes for same camera', () => { + const query = new UnifiedQuery(); + query.addNode(createEventQuery('front')); + query.addNode(createReviewQuery('front')); + query.addNode(createRecordingQuery('front')); + expect(query.getMediaQueries()).toHaveLength(3); + }); + }); + + describe('Lookup Methods', () => { + it('should get queries by folder', () => { + const query = new UnifiedQuery(); + query.addNode(createEventQuery('front')); + query.addNode(createFolderQuery('clips')); + query.addNode(createFolderQuery('recordings')); + + const clipsQueries = query.getFolderQueries('clips'); + expect(clipsQueries).toHaveLength(1); + }); + + it('should get all media types', () => { + const query = new UnifiedQuery(); + query.addNode(createEventQuery('front', { hasClip: true })); + query.addNode(createEventQuery('back', { hasSnapshot: true })); + query.addNode(createRecordingQuery('garage')); + query.addNode(createReviewQuery('office')); + + const types = query.getAllMediaTypes(); + expect(types.size).toBe(4); + expect(types.has('clips')).toBe(true); + expect(types.has('snapshots')).toBe(true); + expect(types.has('recordings')).toBe(true); + expect(types.has('reviews')).toBe(true); + }); + + it('should ignore non-media nodes in getAllMediaTypes', () => { + const query = new UnifiedQuery(); + query.addNode(createEventQuery('front', { hasClip: true })); + query.addNode(createFolderQuery('clips')); + + const types = query.getAllMediaTypes(); + expect(types.size).toBe(1); + expect(types.has('clips')).toBe(true); + }); + + it('should ignore unhandled media types in getAllMediaTypes', () => { + const query = new UnifiedQuery(); + query.addNode({ + source: QuerySource.Camera, + type: QueryType.RecordingSegments, + cameraIDs: new Set(['camera1']), + }); + + const types = query.getAllMediaTypes(); + expect(types.size).toBe(0); + }); + + it('should filter media queries by camera ID', () => { + const query = new UnifiedQuery(); + query.addNode(createEventQuery('front')); + query.addNode(createEventQuery('back')); + + expect(query.getMediaQueries({ cameraID: 'front' })).toHaveLength(1); + expect(query.getMediaQueries({ cameraID: 'garage' })).toHaveLength(0); + }); + + it('should check hasMediaQueriesOfType', () => { + const query = new UnifiedQuery(); + query.addNode(createEventQuery('front')); + + expect(query.hasMediaQueriesOfType(QueryType.Event)).toBe(true); + expect(query.hasMediaQueriesOfType(QueryType.Review)).toBe(false); + }); + }); + + describe('Camera Operations', () => { + it('should get all camera IDs', () => { + const query = new UnifiedQuery(); + query.addNode(createEventQuery('front')); + query.addNode(createReviewQuery('back')); + query.addNode(createFolderQuery('clips')); + + const cameraIDs = query.getAllCameraIDs(); + expect(cameraIDs.size).toBe(2); + expect(cameraIDs.has('front')).toBe(true); + expect(cameraIDs.has('back')).toBe(true); + }); + }); + + describe('Clone', () => { + it('should create deep clone', () => { + const query = new UnifiedQuery(); + query.addNode( + createEventQuery('front', { + what: new Set(['person']), + where: new Set(['driveway']), + }), + ); + + const cloned = query.clone(); + + // Modify original + const originalEvent = query.getMediaQueries()[0]; + assert(isEventQuery(originalEvent)); + originalEvent.what?.add('car'); + + // Clone should be unchanged + const clonedEvent = cloned.getMediaQueries()[0]; + assert(isEventQuery(clonedEvent)); + expect(clonedEvent.what?.has('car')).toBe(false); + expect(clonedEvent.what?.has('person')).toBe(true); + }); + + it('should clone path arrays', () => { + const query = new UnifiedQuery(); + query.addNode(createFolderQuery('clips')); + + const cloned = query.clone(); + + // Modify original + const originalFolder = query.getFolderQueries()[0]; + assert(isFolderQuery(originalFolder)); + + // We need to bypass readonly for testing the clone's independence + (originalFolder as { path: unknown }).path = [ + ...originalFolder.path, + { ha: { id: 'Child' } }, + ]; + + // Clone should be unchanged + const clonedFolder = cloned.getFolderQueries()[0]; + assert(isFolderQuery(clonedFolder)); + expect(clonedFolder.path).toHaveLength(1); + }); + }); + + describe('Equality', () => { + it('should return true for equal queries', () => { + const query1 = new UnifiedQuery(); + query1.addNode( + createEventQuery('front', { + what: new Set(['person']), + }), + ); + + const query2 = new UnifiedQuery(); + query2.addNode( + createEventQuery('front', { + what: new Set(['person']), + }), + ); + + expect(query1.isEqual(query2)).toBe(true); + }); + + it('should return false for different node counts', () => { + const query1 = new UnifiedQuery(); + query1.addNode(createEventQuery('front')); + + const query2 = new UnifiedQuery(); + query2.addNode(createEventQuery('front')); + query2.addNode(createEventQuery('back')); + + expect(query1.isEqual(query2)).toBe(false); + }); + + it('should return false for different node values', () => { + const query1 = new UnifiedQuery(); + query1.addNode(createEventQuery('front', { hasClip: true })); + + const query2 = new UnifiedQuery(); + query2.addNode(createEventQuery('front', { hasClip: false })); + + expect(query1.isEqual(query2)).toBe(false); + }); + + it('should handle Set comparison correctly', () => { + const query1 = new UnifiedQuery(); + query1.addNode( + createEventQuery('front', { + what: new Set(['person', 'car']), + }), + ); + + const query2 = new UnifiedQuery(); + query2.addNode( + createEventQuery('front', { + what: new Set(['car', 'person']), // Same values, different order + }), + ); + + expect(query1.isEqual(query2)).toBe(true); + }); + }); + + describe('isSupersetOf', () => { + it('should return true for identical queries', () => { + const query1 = new UnifiedQuery(); + query1.addNode(createEventQuery('front')); + + const query2 = new UnifiedQuery(); + query2.addNode(createEventQuery('front')); + + expect(query1.isSupersetOf(query2)).toBe(true); + }); + + it('should return true when superset contains all nodes', () => { + const superset = new UnifiedQuery(); + superset.addNode(createEventQuery('front')); + superset.addNode(createEventQuery('back')); + + const subset = new UnifiedQuery(); + subset.addNode(createEventQuery('front')); + + expect(superset.isSupersetOf(subset)).toBe(true); + }); + + it('should return false when target has node not in source', () => { + const query1 = new UnifiedQuery(); + query1.addNode(createEventQuery('front')); + + const query2 = new UnifiedQuery(); + query2.addNode(createEventQuery('front')); + query2.addNode(createEventQuery('back')); + + expect(query1.isSupersetOf(query2)).toBe(false); + }); + + it('should return true when time range is wider', () => { + const superset = new UnifiedQuery(); + superset.addNode( + createEventQuery('front', { + start: new Date('2024-01-01'), + end: new Date('2024-01-31'), + }), + ); + + const subset = new UnifiedQuery(); + subset.addNode( + createEventQuery('front', { + start: new Date('2024-01-10'), + end: new Date('2024-01-20'), + }), + ); + + expect(superset.isSupersetOf(subset)).toBe(true); + }); + + it('should return false when superset time range is narrower', () => { + const narrower = new UnifiedQuery(); + narrower.addNode( + createEventQuery('front', { + start: new Date('2024-01-10'), + end: new Date('2024-01-20'), + }), + ); + + const wider = new UnifiedQuery(); + wider.addNode( + createEventQuery('front', { + start: new Date('2024-01-01'), + end: new Date('2024-01-31'), + }), + ); + + expect(narrower.isSupersetOf(wider)).toBe(false); + }); + + it('should handle null times correctly', () => { + const query1 = new UnifiedQuery(); + query1.addNode(createEventQuery('front')); + + const query2 = new UnifiedQuery(); + query2.addNode(createEventQuery('front')); + + expect(query1.isSupersetOf(query2)).toBe(true); + }); + + it('should return true for empty target query', () => { + const superset = new UnifiedQuery(); + superset.addNode(createEventQuery('front')); + + const empty = new UnifiedQuery(); + + expect(superset.isSupersetOf(empty)).toBe(true); + }); + + it('should support folder nodes', () => { + const superset = new UnifiedQuery(); + superset.addNode(createFolderQuery('f1')); + + const subset = new UnifiedQuery(); + subset.addNode(createFolderQuery('f1')); + + expect(superset.isSupersetOf(subset)).toBe(true); + + const nonSubset = new UnifiedQuery(); + nonSubset.addNode(createFolderQuery('f2')); + expect(superset.isSupersetOf(nonSubset)).toBe(false); + }); + }); +}); diff --git a/tests/view/view-support.test.ts b/tests/view/view-support.test.ts index d611fa70..757392e7 100644 --- a/tests/view/view-support.test.ts +++ b/tests/view/view-support.test.ts @@ -79,6 +79,8 @@ describe('getCameraIDsForViewName', () => { ['snapshots' as const, 'snapshots' as const], ['recording' as const, 'recordings' as const], ['recordings' as const, 'recordings' as const], + ['review' as const, 'reviews' as const], + ['reviews' as const, 'reviews' as const], ])('%s', (viewName: AdvancedCameraCardView, capabilityKey: CapabilityKey) => { const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue( diff --git a/tests/view/view.test.ts b/tests/view/view.test.ts index bdcbdd74..6c88a4a7 100644 --- a/tests/view/view.test.ts +++ b/tests/view/view.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { EventMediaQuery } from '../../src/view/query'; +import { UnifiedQuery } from '../../src/view/unified-query'; import { QueryResults } from '../../src/view/query-results'; import { createView } from '../test-utils'; describe('View Basics', () => { it('should construct from parameters', () => { - const query = new EventMediaQuery(); + const query = new UnifiedQuery(); const queryResults = new QueryResults(); const context = {}; @@ -29,7 +29,7 @@ describe('View Basics', () => { const view = createView({ view: 'live', camera: 'camera', - query: new EventMediaQuery(), + query: new UnifiedQuery(), queryResults: new QueryResults(), context: {}, }); @@ -52,7 +52,7 @@ describe('View Basics', () => { const view = createView({ view: 'live', camera: 'camera-1', - query: new EventMediaQuery(), + query: new UnifiedQuery(), queryResults: new QueryResults(), context: {}, displayMode: 'single', @@ -61,7 +61,7 @@ describe('View Basics', () => { const evolved = view.evolve({ view: 'clips', camera: 'camera-2', - query: new EventMediaQuery(), + query: new UnifiedQuery(), queryResults: new QueryResults(), context: {}, displayMode: 'grid', @@ -78,7 +78,7 @@ describe('View Basics', () => { const view = createView({ view: 'live', camera: 'camera-1', - query: new EventMediaQuery(), + query: new UnifiedQuery(), queryResults: new QueryResults(), context: {}, }); @@ -184,15 +184,16 @@ describe('View Basics', () => { describe('should detect gallery views', () => { it('should detect gallery views', () => { - expect(createView({ view: 'clips' }).isMediaGalleryView()).toBeTruthy(); - expect(createView({ view: 'folders' }).isMediaGalleryView()).toBeTruthy(); - expect(createView({ view: 'snapshots' }).isMediaGalleryView()).toBeTruthy(); - expect(createView({ view: 'recordings' }).isMediaGalleryView()).toBeTruthy(); + expect(createView({ view: 'clips' }).isGalleryView()).toBeTruthy(); + expect(createView({ view: 'folders' }).isGalleryView()).toBeTruthy(); + expect(createView({ view: 'snapshots' }).isGalleryView()).toBeTruthy(); + expect(createView({ view: 'recordings' }).isGalleryView()).toBeTruthy(); + expect(createView({ view: 'reviews' }).isGalleryView()).toBeTruthy(); }); it('should not detect gallery view', () => { - expect(createView({ view: 'live' }).isMediaGalleryView()).toBeFalsy(); - expect(createView({ view: 'timeline' }).isMediaGalleryView()).toBeFalsy(); + expect(createView({ view: 'live' }).isGalleryView()).toBeFalsy(); + expect(createView({ view: 'timeline' }).isGalleryView()).toBeFalsy(); }); }); @@ -202,6 +203,7 @@ describe('View Basics', () => { expect(createView({ view: 'snapshot' }).isViewerView()).toBeTruthy(); expect(createView({ view: 'media' }).isViewerView()).toBeTruthy(); expect(createView({ view: 'recording' }).isViewerView()).toBeTruthy(); + expect(createView({ view: 'review' }).isViewerView()).toBeTruthy(); expect(createView({ view: 'folder' }).isAnyMediaView()).toBeTruthy(); }); @@ -222,6 +224,8 @@ describe('View Basics', () => { expect(createView({ view: 'recordings' }).getDefaultMediaType()).toBe( 'recordings', ); + expect(createView({ view: 'review' }).getDefaultMediaType()).toBe('reviews'); + expect(createView({ view: 'reviews' }).getDefaultMediaType()).toBe('reviews'); }); it('should not get default media type', () => { diff --git a/vite.config.ts b/vite.config.ts index 40938615..4eccad44 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,41 +8,19 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'camera-manager/frigate/camera.ts', 'camera-manager/frigate/requests.ts', 'camera-manager/frigate/util.ts', + 'camera-manager/frigate/watcher.ts', 'camera-manager/generic/*.ts', 'camera-manager/reolink/*.ts', - 'camera-manager/utils/*.ts', + 'camera-manager/utils/**/*.ts', 'card-controller/**/*.ts', - 'components-lib/timeline/!(controller)*.ts', + 'components-lib/**/!(controller).ts', + 'components-lib/!(timeline)/**/controller.ts', 'conditions/**/*.ts', 'config/**/*.ts', 'const.ts', 'ha/**/*.ts', 'types.ts', - 'utils/action.ts', - 'utils/audio.ts', - 'utils/basic.ts', - 'utils/camera.ts', - 'utils/casting.ts', - 'utils/companion.ts', - 'utils/custom-icons.ts', - 'utils/debug.ts', - 'utils/diagnostics.ts', - 'utils/download.ts', - 'utils/embla/**/*.ts', - 'utils/endpoint.ts', - 'utils/initializer.ts', - 'utils/interaction-mode.ts', - 'utils/media-info.ts', - 'utils/media-layout.ts', - 'utils/media.ts', - 'utils/ptz.ts', - 'utils/regexp-extract.ts', - 'utils/screenshot.ts', - 'utils/scroll.ts', - 'utils/substream.ts', - 'utils/text-direction.ts', - 'utils/timer.ts', - 'utils/zod.ts', + 'utils/**/*.ts', 'view/*.ts', ];