Frigate Event Tracking + Automatic Video Download for openHAB

What does this do?

I built an integration that:

  • Tracks Frigate events via MQTT (last 30 per camera)
  • Automatically downloads HLS videos from the Frigate API to NAS
  • Shows nice widgets with thumbnails and direct video playback
  • Automatically deletes old recordings

Works for me with 8 cameras, should work with any number.

What you need

  • Frigate with MQTT
  • openHAB with JavaScript Rules support
  • NAS or mounted network storage for videos
  • ffmpeg on the openHAB host

Setup

1. Mount NAS

I mounted my QNAP via NFS:

bash

sudo mkdir -p /mnt/nas/OH-IPCam
# In /etc/fstab:
192.168.1.100:/volume1/OH-IPCam /mnt/nas/OH-IPCam nfs rw,hard,intr 0 0
sudo mount -a

2. Create Items

You need 3 items per camera - I put them all in one file:

frigate_cameras.items:

// Front Door
String frigate_cam_haustuer_Clip_History "Front Door Clip History" 
String frigate_cam_haustuer_Clip_History_Meta "Front Door Clip History Meta"
Number frigate_cam_haustuer_Clip_History_Length "Front Door Clip Count" <camera>

// Patio
String frigate_cam_freisitz_Clip_History "Patio Clip History"
String frigate_cam_freisitz_Clip_History_Meta "Patio Clip History Meta"
Number frigate_cam_freisitz_Clip_History_Length "Patio Clip Count" <camera>

// ... for each additional camera

Pattern: frigate_cam_[CAMERA]_Clip_History(_Meta / _Length)

3. JavaScript Rules

I built 2 rules:

File 1: frigate_hls_downloader.js

This script downloads HLS videos from Frigate. IMPORTANT:

  • Uses fMP4/CMAF format (not MPEG-TS!)

  • Works with HEVC (H.265) AND H.264

  • Saves to: /mnt/nas/OH-IPCam/[CAMERA]/hls/[DATE]/[EVENT_ID]/

  • ``

javascript

const { rules, triggers } = require('openhab');

// ADJUST: Your Frigate IP!
const FRIGATE_BASE_URL = 'http://192.168.1.16:5000';

const log = (message, level = 'info') => {
    const prefix = 'HLS_DOWNLOAD';
    switch(level) {
        case 'debug': console.debug(`[${prefix}] ${message}`); break;
        case 'warn': console.warn(`[${prefix}] ${message}`); break;
        case 'error': console.error(`[${prefix}] ${message}`); break;
        default: console.info(`[${prefix}] ${message}`);
    }
};

// ADJUST: Your camera names!
const CAMERA_MAPPING = {
    'haustuer': 'haustuer',
    'balkon': 'balkon',
    'freisitz': 'freisitz'
    // ... your cameras
};

const downloadHLSToNAS = (eventId, cameraName, timestamp) => {
    try {
        if (!eventId || !cameraName || !timestamp) {
            log(`Invalid parameters`, 'error');
            return false;
        }

        const nasCameraName = CAMERA_MAPPING[cameraName] || cameraName;
        const dateStr = timestamp.substring(0, 10);
        const targetDir = `/mnt/nas/OH-IPCam/${nasCameraName}/hls/${dateStr}/${eventId}`;
        
        const ProcessBuilder = Java.type('java.lang.ProcessBuilder');
        const TimeUnit = Java.type('java.util.concurrent.TimeUnit');
        
        // Create directory
        const mkdirCmd = new ProcessBuilder('mkdir', '-p', targetDir);
        const mkdirProc = mkdirCmd.start();
        mkdirProc.waitFor(10, TimeUnit.SECONDS);
        
        log(`đź“‚ Directory: ${targetDir}`, 'debug');
        
        // Download thumbnail
        const thumbnailUrl = `${FRIGATE_BASE_URL}/api/events/${eventId}/thumbnail.jpg`;
        const wgetThumbCmd = new ProcessBuilder(
            'wget', '-q', '-O', `${targetDir}/thumbnail.jpg`, thumbnailUrl
        );
        
        log(`📸 Thumbnail: ${eventId}`, 'debug');
        wgetThumbCmd.start().waitFor(30, TimeUnit.SECONDS);
        
        // Download HLS with ffmpeg
        const hlsUrl = `${FRIGATE_BASE_URL}/vod/event/${eventId}/master.m3u8`;
        
        const ffmpegCmd = new ProcessBuilder(
            'ffmpeg', '-y',
            '-loglevel', 'error',
            '-allowed_extensions', 'ALL',
            '-protocol_whitelist', 'file,http,https,tcp,tls',
            '-i', hlsUrl,
            '-c', 'copy',  // No re-encode!
            '-f', 'hls',
            '-hls_time', '10',
            '-hls_list_size', '0',
            '-hls_playlist_type', 'vod',
            '-hls_segment_type', 'fmp4',  // fMP4 instead of MPEG-TS!
            '-hls_fmp4_init_filename', 'init.mp4',
            '-hls_segment_filename', `${targetDir}/segment-%03d.m4s`,
            `${targetDir}/playlist.m3u8`
        );
        
        log(`⬇️ HLS Download: ${eventId}`, 'info');
        
        const ffmpegProc = ffmpegCmd.start();
        const success = ffmpegProc.waitFor(300, TimeUnit.SECONDS);
        
        if (!success || ffmpegProc.exitValue() !== 0) {
            log(`❌ Download failed: ${eventId}`, 'error');
            return false;
        }
        
        // Verify
        java.lang.Thread.sleep(500);
        
        const Files = Java.type('java.nio.file.Files');
        const Paths = Java.type('java.nio.file.Paths');
        
        if (!Files.exists(Paths.get(`${targetDir}/playlist.m3u8`))) {
            log(`❌ No playlist: ${eventId}`, 'error');
            return false;
        }
        
        const File = Java.type('java.io.File');
        const files = new File(targetDir).listFiles();
        const segments = files ? Array.from(files).filter(f => f.getName().endsWith('.m4s')).length : 0;
        
        log(`âś… Download OK: ${eventId} (${segments} segments)`, 'info');
        return true;
        
    } catch (error) {
        log(`Error: ${error}`, 'error');
        return false;
    }
};

const cleanupOldHLS = (maxAgeDays = 5) => {
    try {
        log(`đź§ą Cleanup (older than ${maxAgeDays} days)`, 'info');
        
        const cameras = Object.values(CAMERA_MAPPING);
        const now = new Date();
        const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
        let totalDeleted = 0;
        
        cameras.forEach(camera => {
            const hlsPath = `/mnt/nas/OH-IPCam/${camera}/hls`;
            const File = Java.type('java.io.File');
            const hlsDir = new File(hlsPath);
            
            if (!hlsDir.exists()) return;
            
            const dateDirs = hlsDir.listFiles();
            if (!dateDirs) return;
            
            Array.from(dateDirs).forEach(dateDir => {
                if (!dateDir.isDirectory()) return;
                
                const dirName = dateDir.getName();
                const parts = dirName.split('-');
                if (parts.length !== 3) return;
                
                const dirDate = new Date(
                    parseInt(parts[0]),
                    parseInt(parts[1]) - 1,
                    parseInt(parts[2])
                );
                
                if (isNaN(dirDate.getTime())) return;
                
                if ((now - dirDate) > maxAgeMs) {
                    const deleteRecursive = (dir) => {
                        const files = dir.listFiles();
                        if (files) {
                            Array.from(files).forEach(f => {
                                if (f.isDirectory()) deleteRecursive(f);
                                else f.delete();
                            });
                        }
                        dir.delete();
                    };
                    
                    deleteRecursive(dateDir);
                    totalDeleted++;
                    log(`🗑️ Deleted: ${camera}/${dirName}`, 'debug');
                }
            });
        });
        
        log(`âś… Cleanup complete: ${totalDeleted} folders deleted`, 'info');
        return totalDeleted;
        
    } catch (error) {
        log(`Error: ${error}`, 'error');
        return 0;
    }
};

// Export
if (typeof module !== 'undefined' && module.exports) {
    module.exports = {
        downloadHLSToNAS,
        cleanupOldHLS,
        CAMERA_MAPPING,
        log
    };
}

log('âś… HLS Downloader loaded', 'info');

File 2: frigate_event_tracker.js

This script tracks events and calls the downloader:

javascript

const { rules, triggers, items, time } = require('openhab');
const downloader = require('./frigate_hls_downloader.js');

// ADJUST: Your camera names (Frigate → openHAB item suffix)
const CAMERA_MAPPING = {
    'haustuer': 'haustuer',
    'balkon': 'balkon',
    'freisitz': 'freisitz'
    // ... your cameras
};

const MAX_EVENTS = 30;

const log = (message, level = 'info') => {
    const prefix = 'EVENT_TRACKER';
    switch(level) {
        case 'debug': console.debug(`[${prefix}] ${message}`); break;
        case 'warn': console.warn(`[${prefix}] ${message}`); break;
        case 'error': console.error(`[${prefix}] ${message}`); break;
        default: console.info(`[${prefix}] ${message}`);
    }
};

// Event Handler
rules.JSRule({
    name: "Frigate Event Tracker + Downloader",
    description: "Tracks events and downloads HLS videos",
    triggers: [
        triggers.ChannelEventTrigger('mqtt:topic:mosquitto:frigate', 'frigate/events')
    ],
    execute: (event) => {
        try {
            const payload = JSON.parse(event.event);
            
            // Only "end" events
            if (payload.type !== 'end') return;
            
            const after = payload.after;
            if (!after) return;
            
            const cameraFrigate = after.camera;
            const cameraOH = CAMERA_MAPPING[cameraFrigate];
            
            if (!cameraOH) {
                log(`Unknown camera: ${cameraFrigate}`, 'warn');
                return;
            }
            
            const eventId = after.id;
            const label = after.label || 'unknown';
            const startTime = new Date(after.start_time * 1000).toISOString();
            
            log(`📹 Event: ${cameraFrigate} - ${label} (${eventId})`, 'info');
            
            // Update items
            const historyItem = `frigate_cam_${cameraOH}_Clip_History`;
            const metaItem = `frigate_cam_${cameraOH}_Clip_History_Meta`;
            const lengthItem = `frigate_cam_${cameraOH}_Clip_History_Length`;
            
            // Load old history
            let eventIds = [];
            let eventMeta = [];
            
            const currentHistory = items.getItem(historyItem).state;
            if (currentHistory && currentHistory !== 'NULL' && currentHistory !== 'UNDEF') {
                eventIds = currentHistory.split(',');
            }
            
            const currentMeta = items.getItem(metaItem).state;
            if (currentMeta && currentMeta !== 'NULL' && currentMeta !== 'UNDEF') {
                eventMeta = currentMeta.split('|~|');
            }
            
            // Add new event at front
            eventIds.unshift(eventId);
            eventMeta.unshift(`${cameraFrigate}|${label}|${startTime}`);
            
            // Limit to MAX_EVENTS
            if (eventIds.length > MAX_EVENTS) {
                eventIds = eventIds.slice(0, MAX_EVENTS);
                eventMeta = eventMeta.slice(0, MAX_EVENTS);
            }
            
            // Update items
            items.getItem(historyItem).postUpdate(eventIds.join(','));
            items.getItem(metaItem).postUpdate(eventMeta.join('|~|'));
            items.getItem(lengthItem).postUpdate(eventIds.length);
            
            log(`âś… History updated: ${cameraFrigate} (${eventIds.length} events)`, 'info');
            
            // Start HLS download (async)
            const success = downloader.downloadHLSToNAS(eventId, cameraFrigate, startTime);
            if (!success) {
                log(`⚠️ Download failed: ${eventId}`, 'warn');
            }
            
        } catch (error) {
            log(`Error: ${error}`, 'error');
        }
    }
});

// Daily cleanup at 3 AM
rules.JSRule({
    name: "Frigate HLS Cleanup",
    description: "Deletes old HLS folders",
    triggers: [
        triggers.GenericCronTrigger('0 0 3 * * ?')
    ],
    execute: () => {
        downloader.cleanupOldHLS(5);  // 5 days retention
    }
});

log('âś… Event Tracker loaded', 'info');

4. Widgets

I built 3 widgets. The code is included below - you can easily extend them to more cameras/events or try using oh-repeater (I couldn’t get it to work reliably, but maybe you’ll have better luck!).

Widget 1: CameraSnapshotHistory_Frigate_v1

Shows overview of all cameras with event count badges. This example shows 3 cameras - just copy the pattern for more.

yaml

uid: CameraSnapshotHistory_Frigate_v1
tags: []
props:
  parameters:
    - description: Refresh rate in milliseconds
      label: Refresh Rate
      name: refreshRate
      required: false
      type: INTEGER
      default: 5000
    
    # Camera 1
    - context: item
      description: Camera 1 image item
      label: Camera 1 Image Item
      name: camera1ImageItem
      required: false
      type: TEXT
    - description: Camera 1 display name
      label: Camera 1 Name
      name: camera1Name
      required: false
      type: TEXT
    - context: item
      description: Camera 1 clip history length
      label: Camera 1 Clip History Length
      name: camera1ClipHistoryLengthItem
      required: false
      type: TEXT
    - description: Camera 1 event page UID
      label: Camera 1 Page UID
      name: camera1PageUid
      required: false
      type: TEXT
    
    # Camera 2
    - context: item
      description: Camera 2 image item
      label: Camera 2 Image Item
      name: camera2ImageItem
      required: false
      type: TEXT
    - description: Camera 2 display name
      label: Camera 2 Name
      name: camera2Name
      required: false
      type: TEXT
    - context: item
      description: Camera 2 clip history length
      label: Camera 2 Clip History Length
      name: camera2ClipHistoryLengthItem
      required: false
      type: TEXT
    - description: Camera 2 event page UID
      label: Camera 2 Page UID
      name: camera2PageUid
      required: false
      type: TEXT
    
    # Camera 3
    - context: item
      description: Camera 3 image item
      label: Camera 3 Image Item
      name: camera3ImageItem
      required: false
      type: TEXT
    - description: Camera 3 display name
      label: Camera 3 Name
      name: camera3Name
      required: false
      type: TEXT
    - context: item
      description: Camera 3 clip history length
      label: Camera 3 Clip History Length
      name: camera3ClipHistoryLengthItem
      required: false
      type: TEXT
    - description: Camera 3 event page UID
      label: Camera 3 Page UID
      name: camera3PageUid
      required: false
      type: TEXT

timestamp: Dec 28, 2025, 10:00:00 PM
component: f7-block
config:
  style:
    margin: 0
    padding: 10px
slots:
  default:
    # Camera 1
    - component: f7-card
      config:
        style:
          margin-bottom: 15px
        visible: "=props.camera1ImageItem ? true : false"
      slots:
        default:
          - component: f7-card-content
            config:
              style:
                padding: 0
            slots:
              default:
                - component: oh-link
                  config:
                    action: navigate
                    actionPage: "=props.camera1PageUid"
                  slots:
                    default:
                      - component: f7-block
                        config:
                          style:
                            margin: 0
                            padding: 0
                            position: relative
                        slots:
                          default:
                            - component: oh-image
                              config:
                                refreshInterval: "=props.refreshRate || 5000"
                                style:
                                  aspect-ratio: 16/9
                                  display: block
                                  object-fit: cover
                                  width: 100%
                                url: "='/rest/items/' + props.camera1ImageItem + '/state'"
                            - component: f7-badge
                              config:
                                style:
                                  background: var(--f7-theme-color)
                                  border-radius: 20px
                                  font-size: 14px
                                  font-weight: bold
                                  padding: 8px 16px
                                  position: absolute
                                  right: 12px
                                  top: 12px
                                  z-index: 10
                                text: "=items[props.camera1ClipHistoryLengthItem].state || '0'"
                      - component: f7-card-content
                        config:
                          style:
                            padding: 12px 15px
                        slots:
                          default:
                            - component: Label
                              config:
                                style:
                                  font-size: 18px
                                  font-weight: bold
                                text: "=props.camera1Name"
    
    # Camera 2
    - component: f7-card
      config:
        style:
          margin-bottom: 15px
        visible: "=props.camera2ImageItem ? true : false"
      slots:
        default:
          - component: f7-card-content
            config:
              style:
                padding: 0
            slots:
              default:
                - component: oh-link
                  config:
                    action: navigate
                    actionPage: "=props.camera2PageUid"
                  slots:
                    default:
                      - component: f7-block
                        config:
                          style:
                            margin: 0
                            padding: 0
                            position: relative
                        slots:
                          default:
                            - component: oh-image
                              config:
                                refreshInterval: "=props.refreshRate || 5000"
                                style:
                                  aspect-ratio: 16/9
                                  display: block
                                  object-fit: cover
                                  width: 100%
                                url: "='/rest/items/' + props.camera2ImageItem + '/state'"
                            - component: f7-badge
                              config:
                                style:
                                  background: var(--f7-theme-color)
                                  border-radius: 20px
                                  font-size: 14px
                                  font-weight: bold
                                  padding: 8px 16px
                                  position: absolute
                                  right: 12px
                                  top: 12px
                                  z-index: 10
                                text: "=items[props.camera2ClipHistoryLengthItem].state || '0'"
                      - component: f7-card-content
                        config:
                          style:
                            padding: 12px 15px
                        slots:
                          default:
                            - component: Label
                              config:
                                style:
                                  font-size: 18px
                                  font-weight: bold
                                text: "=props.camera2Name"
    
    # Camera 3
    - component: f7-card
      config:
        style:
          margin-bottom: 15px
        visible: "=props.camera3ImageItem ? true : false"
      slots:
        default:
          - component: f7-card-content
            config:
              style:
                padding: 0
            slots:
              default:
                - component: oh-link
                  config:
                    action: navigate
                    actionPage: "=props.camera3PageUid"
                  slots:
                    default:
                      - component: f7-block
                        config:
                          style:
                            margin: 0
                            padding: 0
                            position: relative
                        slots:
                          default:
                            - component: oh-image
                              config:
                                refreshInterval: "=props.refreshRate || 5000"
                                style:
                                  aspect-ratio: 16/9
                                  display: block
                                  object-fit: cover
                                  width: 100%
                                url: "='/rest/items/' + props.camera3ImageItem + '/state'"
                            - component: f7-badge
                              config:
                                style:
                                  background: var(--f7-theme-color)
                                  border-radius: 20px
                                  font-size: 14px
                                  font-weight: bold
                                  padding: 8px 16px
                                  position: absolute
                                  right: 12px
                                  top: 12px
                                  z-index: 10
                                text: "=items[props.camera3ClipHistoryLengthItem].state || '0'"
                      - component: f7-card-content
                        config:
                          style:
                            padding: 12px 15px
                        slots:
                          default:
                            - component: Label
                              config:
                                style:
                                  font-size: 18px
                                  font-weight: bold
                                text: "=props.camera3Name"

Widget 2: EventBrowser_Frigate_v1

Shows event list with thumbnails. This example shows 5 events - extend by copying the pattern or try using oh-repeater.

yaml

uid: EventBrowser_Frigate_v1
tags: []
props:
  parameters:
    - description: Technical name (e.g. 'front_door', 'patio')
      label: Camera Name
      name: cameraName
      required: true
      type: TEXT
    - description: Display name (e.g. 'Front Door', 'Patio')
      label: Camera Display Name
      name: cameraDisplayName
      required: true
      type: TEXT
    - context: item
      description: Item with event IDs (comma-separated)
      label: Clip History Item
      name: clipHistoryItem
      required: true
      type: TEXT
    - context: item
      description: Item with metadata (|~| separated)
      label: Clip History Meta Item
      name: clipHistoryMetaItem
      required: true
      type: TEXT
    - context: item
      description: Item with event count
      label: Clip History Length Item
      name: clipHistoryLengthItem
      required: true
      type: TEXT
timestamp: Dec 28, 2025, 10:00:00 PM
component: f7-block
config:
  style:
    margin: 0
    padding: 10px
slots:
  default:
    # Empty state
    - component: f7-block
      config:
        style:
          color: var(--f7-text-color)
          padding: 60px 20px
          text-align: center
        visible: =items[props.clipHistoryLengthItem].state == 0
      slots:
        default:
          - component: Label
            config:
              style:
                font-size: 64px
                margin-bottom: 15px
              text: 📹
          - component: Label
            config:
              style:
                color: var(--f7-text-color-secondary)
                display: block
                font-size: 16px
              text: No events stored
    
    # Event list
    - component: f7-block
      config:
        style:
          margin: 0
          padding: 0
        visible: =items[props.clipHistoryLengthItem].state > 0
      slots:
        default:
          # Event 0
          - component: f7-card
            config:
              style:
                margin-bottom: 15px
              visible: =items[props.clipHistoryLengthItem].state >= 1
            slots:
              default:
                - component: f7-card-content
                  config:
                    style:
                      padding: 0
                  slots:
                    default:
                      - component: f7-block
                        config:
                          style:
                            margin: 0
                            padding: 0
                            position: relative
                        slots:
                          default:
                            - component: oh-image
                              config:
                                style:
                                  aspect-ratio: 16/9
                                  background: "#000"
                                  display: block
                                  object-fit: cover
                                  width: 100%
                                url: ='/static/ipcam/' + props.cameraName + '/hls/' + items[props.clipHistoryMetaItem].state.split('|~|')[0].split('|')[2].substring(0,10) + '/' + items[props.clipHistoryItem].state.split(',')[0] + '/thumbnail.jpg'
                      - component: f7-card-content
                        config:
                          style:
                            padding: 12px 15px
                        slots:
                          default:
                            - component: Label
                              config:
                                style:
                                  display: block
                                  font-size: 16px
                                  font-weight: bold
                                  margin-bottom: 8px
                                text: =items[props.clipHistoryMetaItem].state.split('|~|')[0].split('|')[2].substring(8,10) + '.' + items[props.clipHistoryMetaItem].state.split('|~|')[0].split('|')[2].substring(5,7) + '.' + items[props.clipHistoryMetaItem].state.split('|~|')[0].split('|')[2].substring(0,4) + ' ' + items[props.clipHistoryMetaItem].state.split('|~|')[0].split('|')[2].substring(11,16)
                            - component: Label
                              config:
                                style:
                                  color: var(--f7-text-color-secondary)
                                  display: block
                                  font-size: 14px
                                  margin-bottom: 12px
                                text: "='Object: ' + items[props.clipHistoryMetaItem].state.split('|~|')[0].split('|')[1]"
                            - component: oh-link
                              config:
                                action: popup
                                actionModal: widget:FrigateEventVideoPlayer
                                actionModalConfig:
                                  cameraName: =props.cameraName
                                  eventId: =items[props.clipHistoryItem].state.split(',')[0]
                                  eventDate: =items[props.clipHistoryMetaItem].state.split('|~|')[0].split('|')[2].substring(0,10)
                                  eventLabel: =items[props.clipHistoryMetaItem].state.split('|~|')[0].split('|')[1]
                                  eventTime: =items[props.clipHistoryMetaItem].state.split('|~|')[0].split('|')[2].substring(11,16)
                                style:
                                  align-items: center
                                  background: var(--f7-theme-color)
                                  border-radius: 8px
                                  color: white
                                  display: flex
                                  font-weight: 500
                                  height: 44px
                                  justify-content: center
                                  text-decoration: none
                              slots:
                                default:
                                  - component: f7-icon
                                    config:
                                      f7: play_circle
                                      size: 20
                                      style:
                                        margin-right: 8px
                                  - component: Label
                                    config:
                                      text: Play Video
          
          # Event 1
          - component: f7-card
            config:
              style:
                margin-bottom: 15px
              visible: =items[props.clipHistoryLengthItem].state >= 2
            slots:
              default:
                - component: f7-card-content
                  config:
                    style:
                      padding: 0
                  slots:
                    default:
                      - component: f7-block
                        config:
                          style:
                            margin: 0
                            padding: 0
                            position: relative
                        slots:
                          default:
                            - component: oh-image
                              config:
                                style:
                                  aspect-ratio: 16/9
                                  background: "#000"
                                  display: block
                                  object-fit: cover
                                  width: 100%
                                url: ='/static/ipcam/' + props.cameraName + '/hls/' + items[props.clipHistoryMetaItem].state.split('|~|')[1].split('|')[2].substring(0,10) + '/' + items[props.clipHistoryItem].state.split(',')[1] + '/thumbnail.jpg'
                      - component: f7-card-content
                        config:
                          style:
                            padding: 12px 15px
                        slots:
                          default:
                            - component: Label
                              config:
                                style:
                                  display: block
                                  font-size: 16px
                                  font-weight: bold
                                  margin-bottom: 8px
                                text: =items[props.clipHistoryMetaItem].state.split('|~|')[1].split('|')[2].substring(8,10) + '.' + items[props.clipHistoryMetaItem].state.split('|~|')[1].split('|')[2].substring(5,7) + '.' + items[props.clipHistoryMetaItem].state.split('|~|')[1].split('|')[2].substring(0,4) + ' ' + items[props.clipHistoryMetaItem].state.split('|~|')[1].split('|')[2].substring(11,16)
                            - component: Label
                              config:
                                style:
                                  color: var(--f7-text-color-secondary)
                                  display: block
                                  font-size: 14px
                                  margin-bottom: 12px
                                text: "='Object: ' + items[props.clipHistoryMetaItem].state.split('|~|')[1].split('|')[1]"
                            - component: oh-link
                              config:
                                action: popup
                                actionModal: widget:FrigateEventVideoPlayer
                                actionModalConfig:
                                  cameraName: =props.cameraName
                                  eventId: =items[props.clipHistoryItem].state.split(',')[1]
                                  eventDate: =items[props.clipHistoryMetaItem].state.split('|~|')[1].split('|')[2].substring(0,10)
                                  eventLabel: =items[props.clipHistoryMetaItem].state.split('|~|')[1].split('|')[1]
                                  eventTime: =items[props.clipHistoryMetaItem].state.split('|~|')[1].split('|')[2].substring(11,16)
                                style:
                                  align-items: center
                                  background: var(--f7-theme-color)
                                  border-radius: 8px
                                  color: white
                                  display: flex
                                  font-weight: 500
                                  height: 44px
                                  justify-content: center
                                  text-decoration: none
                              slots:
                                default:
                                  - component: f7-icon
                                    config:
                                      f7: play_circle
                                      size: 20
                                      style:
                                        margin-right: 8px
                                  - component: Label
                                    config:
                                      text: Play Video
          
          # Events 2-4: Copy pattern above and change indices
          # For more than 5 events, continue the pattern or try using oh-repeater

Widget 3: FrigateEventVideoPlayer

Full-screen video player popup:

yaml

uid: FrigateEventVideoPlayer
tags: []
props:
  parameters:
    - name: cameraName
      label: Camera Name
      type: TEXT
      required: true
    - name: eventId
      label: Event ID
      type: TEXT
      required: true
    - name: eventDate
      label: Event Date (YYYY-MM-DD)
      type: TEXT
      required: true
    - name: eventLabel
      label: Event Label
      type: TEXT
      required: true
    - name: eventTime
      label: Event Time (HH:MM)
      type: TEXT
      required: true
component: f7-page
config:
  style:
    background: black
    height: 100vh
    display: flex
    flex-direction: column
slots:
  default:
    - component: f7-navbar
      config:
        style:
          background: rgba(0,0,0,0.95)
          flex-shrink: 0
      slots:
        title:
          - component: f7-nav-title
            config:
              style:
                color: white
                font-size: 16px
              title: "=props.eventTime + ' • ' + props.eventLabel"
        left:
          - component: f7-link
            config:
              iconF7: xmark
              iconSize: 24
              iconColor: white
              back: true
    
    - component: oh-video-card
      config:
        noBorder: true
        noShadow: true
        style:
          flex: 1
          width: 100%
          height: 100%
          background: black
        url: "='/static/ipcam/' + props.cameraName + '/hls/' + props.eventDate + '/' + props.eventId + '/playlist.m3u8'"

What I learned

HEVC vs H.264

If you use HEVC (H.265):

  • IMPORTANT: Use fMP4/CMAF, NOT MPEG-TS!
  • MPEG-TS with HEVC doesn’t play in most browsers
  • fMP4 works everywhere

That’s why in the ffmpeg command:

-hls_segment_type fmp4

Widget extensibility

The widgets can be easily extended:

  • CameraSnapshotHistory: Just copy the camera block for more cameras
  • EventBrowser: Copy the event block for more events (I have a version with 30 hardcoded events)
  • oh-repeater: Should work to make this dynamic, but I couldn’t get it working reliably

Summary and Development

Summary and development with help from Claude (Anthropic AI), so if anything does not work don’t blame me :wink:

2 Likes

This is a great tutorial. A lot of people use Frigate and these make great candidates for the marketplace. The rules and the widgets could be simply installed from the Add-on store instead of copy/paste/edit.

The widgets can be posted as is (one post per widget) but the rule would need to be converted to a rule template. See How and Why to Write a Rule Template (revisited) for details on that if it’s something you are interested in doing.

You could merge your two rules into one by checking to see which type of trigger caused the rule to run and running the cleanup if it wasn’t triggered by the Channel event.

I’d love it if we could have more than one rule per template but until that happens, using tricks like this to put it all in one rule makes it much easier for the end users to use.