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 ![]()




