From e6828d8a225512872d487420e7f1083207f0e3e5 Mon Sep 17 00:00:00 2001 From: eduard256 Date: Wed, 26 Nov 2025 15:29:56 +0300 Subject: [PATCH 1/4] Add ?mp4 parameter support for BUBBLE streams in Frigate config - Add buildRtspPath() helper method to conditionally append ?mp4 - Only BUBBLE stream types get ?mp4 suffix for proper recording - Other stream types (RTSP, ONVIF, JPEG, etc.) remain unchanged - Handles both single and dual-stream configurations correctly --- .../web/js/config-generators/frigate/index.js | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/webui/web/js/config-generators/frigate/index.js b/webui/web/js/config-generators/frigate/index.js index 61227f7..379877b 100644 --- a/webui/web/js/config-generators/frigate/index.js +++ b/webui/web/js/config-generators/frigate/index.js @@ -252,6 +252,15 @@ export class FrigateGenerator { return lines; } + /** + * Build RTSP path with optional ?mp4 suffix for BUBBLE streams + */ + static buildRtspPath(streamName, streamType) { + const basePath = `rtsp://127.0.0.1:8554/${streamName}`; + // Add ?mp4 parameter only for BUBBLE streams to enable recording in Frigate + return streamType === 'BUBBLE' ? `${basePath}?mp4` : basePath; + } + /** * Generate camera lines for cameras section */ @@ -264,11 +273,14 @@ export class FrigateGenerator { if (cameraInfo.subStream) { // Use sub for detect, main for record - lines.push(` - path: rtsp://127.0.0.1:8554/${cameraInfo.subStreamName}`); + const subPath = this.buildRtspPath(cameraInfo.subStreamName, cameraInfo.subStream.type); + const mainPath = this.buildRtspPath(cameraInfo.mainStreamName, cameraInfo.mainStream.type); + + lines.push(` - path: ${subPath}`); lines.push(' input_args: preset-rtsp-restream'); lines.push(' roles:'); lines.push(' - detect'); - lines.push(` - path: rtsp://127.0.0.1:8554/${cameraInfo.mainStreamName}`); + lines.push(` - path: ${mainPath}`); lines.push(' input_args: preset-rtsp-restream'); lines.push(' roles:'); lines.push(' - record'); @@ -280,7 +292,9 @@ export class FrigateGenerator { lines.push(` Sub Stream: ${cameraInfo.subStreamName} # Низкое разрешение (опционально)`); } else { // Use main for both detect and record - lines.push(` - path: rtsp://127.0.0.1:8554/${cameraInfo.mainStreamName}`); + const mainPath = this.buildRtspPath(cameraInfo.mainStreamName, cameraInfo.mainStream.type); + + lines.push(` - path: ${mainPath}`); lines.push(' input_args: preset-rtsp-restream'); lines.push(' roles:'); lines.push(' - detect'); From 915c1dec1b42bd576642ed00c24aafac4109c067 Mon Sep 17 00:00:00 2001 From: eduard256 Date: Thu, 11 Dec 2025 09:49:34 +0000 Subject: [PATCH 2/4] Add stream categorization: Recommended/Alternative with Main/Sub/Other subgroups - Split discovered streams into Recommended (FFMPEG, ONVIF) and Alternative groups - Add Main/Sub/Other classification within Recommended based on resolution: - Main: streams with width >= 720px (after clustering analysis) - Sub: streams with width < 720px or lower cluster - Other: streams without resolution info - Implement smart auto-collapse based on selection mode: - Main selection: shows Recommended/Main, collapses rest - Sub selection: shows Recommended/Sub, collapses rest - Falls back to showing all if target category is empty - Add collapsible groups/subgroups with chevron toggles - User manual expand/collapse preserved until mode change - Add stream count badges to all group headers --- webui/web/css/main.css | 147 ++++++++++++- webui/web/js/main.js | 13 +- webui/web/js/mock/mock-stream-api.js | 248 ++++++++++++++++++---- webui/web/js/ui/stream-list.js | 303 ++++++++++++++++++++++++++- 4 files changed, 665 insertions(+), 46 deletions(-) diff --git a/webui/web/css/main.css b/webui/web/css/main.css index 832f78e..4c5d0ab 100644 --- a/webui/web/css/main.css +++ b/webui/web/css/main.css @@ -590,10 +590,155 @@ body { .streams-list { display: flex; flex-direction: column; - gap: var(--space-3); + gap: var(--space-6); padding: var(--space-2); } +/* ===== STREAM GROUPS ===== */ +.stream-group { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.stream-group-header { + display: flex; + align-items: center; + gap: var(--space-2); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--border-color); + cursor: pointer; + user-select: none; + transition: color var(--transition-fast); +} + +.stream-group-header:hover { + color: var(--purple-primary); +} + +.stream-group-toggle { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--text-tertiary); + transition: all var(--transition-fast); +} + +.stream-group-toggle .chevron { + transition: transform var(--transition-fast); +} + +.stream-group.collapsed .stream-group-toggle .chevron { + transform: rotate(-90deg); +} + +.stream-group.collapsed .stream-group-content { + display: none; +} + +.stream-group-title { + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.stream-group-count { + font-size: var(--text-sm); + font-weight: 400; + color: var(--text-tertiary); +} + +.stream-group-content { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.stream-group-empty { + padding: var(--space-4); + text-align: center; + color: var(--text-tertiary); + font-size: var(--text-sm); + background: var(--bg-secondary); + border: 1px dashed var(--border-color); + border-radius: 8px; +} + +/* ===== STREAM SUBGROUPS (Main/Sub/Other within Recommended) ===== */ +.stream-subgroup { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.stream-subgroup:not(:last-child) { + margin-bottom: var(--space-4); +} + +.stream-subgroup-header { + display: flex; + align-items: center; + gap: var(--space-2); + padding-left: var(--space-2); + cursor: pointer; + user-select: none; + transition: color var(--transition-fast); +} + +.stream-subgroup-header:hover { + color: var(--purple-primary); +} + +.stream-subgroup-toggle { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--text-tertiary); + transition: all var(--transition-fast); +} + +.stream-subgroup-toggle .chevron { + transition: transform var(--transition-fast); +} + +.stream-subgroup.collapsed .stream-subgroup-toggle .chevron { + transform: rotate(-90deg); +} + +.stream-subgroup.collapsed .stream-subgroup-content { + display: none; +} + +.stream-subgroup-title { + font-size: var(--text-xs); + font-weight: 500; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.stream-subgroup-count { + font-size: var(--text-xs); + font-weight: 400; + color: var(--text-disabled); +} + +.stream-subgroup-content { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + /* Custom scrollbar */ .streams-list::-webkit-scrollbar { width: 8px; diff --git a/webui/web/js/main.js b/webui/web/js/main.js index e0ffad2..fa2fccf 100644 --- a/webui/web/js/main.js +++ b/webui/web/js/main.js @@ -315,6 +315,11 @@ class StrixApp { document.getElementById('progress-text').textContent = 'Starting scan...'; document.getElementById('streams-section').classList.add('hidden'); this.currentStreams = []; + // Reset stream list state for fresh discovery + this.streamList.selectionMode = 'main'; + this.streamList.collapsedGroups.clear(); + this.streamList.collapsedSubgroups.clear(); + this.streamList.needsSmartDefaults = true; } handleProgress(data) { @@ -334,7 +339,7 @@ class StrixApp { streamsSection.classList.remove('hidden'); } - // Update stream list + // Update stream list (smart defaults applied automatically on first render) this.streamList.render(this.currentStreams, (stream, index) => { this.selectStream(stream, index); }); @@ -391,6 +396,12 @@ class StrixApp { document.getElementById('frigate-output-section').classList.add('hidden'); document.getElementById('config-frigate').textContent = ''; + // Set stream list to sub selection mode (will collapse Main, show Sub) + this.streamList.setSelectionMode('sub'); + this.streamList.render(this.currentStreams, (stream, index) => { + this.selectStream(stream, index); + }); + showToast('Select a sub stream from available streams'); this.showScreen('discovery'); } diff --git a/webui/web/js/mock/mock-stream-api.js b/webui/web/js/mock/mock-stream-api.js index 8ce1b24..5747a1b 100644 --- a/webui/web/js/mock/mock-stream-api.js +++ b/webui/web/js/mock/mock-stream-api.js @@ -2,6 +2,7 @@ export class MockStreamAPI { constructor() { this.mockStreams = [ + // RTSP Main streams (1920x1080) { url: "rtsp://192.168.1.100:554/Streaming/Channels/101", path: "/Streaming/Channels/101", @@ -12,6 +13,27 @@ export class MockStreamAPI { bitrate: 4096000, has_audio: true }, + { + url: "rtsp://192.168.1.100:554/live/main", + path: "/live/main", + type: "FFMPEG", + resolution: "1920x1080", + codec: "H.264", + fps: 30, + bitrate: 4608000, + has_audio: true + }, + { + url: "rtsp://192.168.1.100:554/stream1", + path: "/stream1", + type: "FFMPEG", + resolution: "1920x1080", + codec: "H.265", + fps: 25, + bitrate: 3584000, + has_audio: true + }, + // JPEG snapshots (5 items in different positions) { url: "http://192.168.1.100/snap.jpg", path: "/snap.jpg", @@ -22,16 +44,124 @@ export class MockStreamAPI { bitrate: 0, has_audio: false }, + // RTSP Sub streams (640x480) + { + url: "rtsp://192.168.1.100:554/Streaming/Channels/102", + path: "/Streaming/Channels/102", + type: "FFMPEG", + resolution: "640x480", + codec: "H.264", + fps: 5, + bitrate: 512000, + has_audio: true + }, + { + url: "rtsp://192.168.1.100:554/live/sub", + path: "/live/sub", + type: "FFMPEG", + resolution: "640x480", + codec: "H.264", + fps: 10, + bitrate: 768000, + has_audio: false + }, + // JPEG #2 + { + url: "http://192.168.1.100/cgi-bin/snapshot.cgi", + path: "/cgi-bin/snapshot.cgi", + type: "JPEG", + resolution: "1920x1080", + codec: "JPEG", + fps: 1, + bitrate: 0, + has_audio: false + }, + { + url: "rtsp://192.168.1.100:554/stream2", + path: "/stream2", + type: "FFMPEG", + resolution: "640x480", + codec: "H.264", + fps: 15, + bitrate: 640000, + has_audio: false + }, + // ONVIF streams + { + url: "rtsp://192.168.1.100:554/onvif/profile0", + path: "/onvif/profile0", + type: "ONVIF", + resolution: "1920x1080", + codec: "H.264", + fps: 25, + bitrate: 4096000, + has_audio: true + }, + { + url: "rtsp://192.168.1.100:554/onvif/profile1", + path: "/onvif/profile1", + type: "ONVIF", + resolution: "640x480", + codec: "H.264", + fps: 15, + bitrate: 512000, + has_audio: false + }, + // JPEG #3 + { + url: "http://192.168.1.100/image/jpeg.cgi", + path: "/image/jpeg.cgi", + type: "JPEG", + resolution: "1920x1080", + codec: "JPEG", + fps: 1, + bitrate: 0, + has_audio: false + }, + // More RTSP variants + { + url: "rtsp://192.168.1.100:554/cam/realmonitor?channel=1&subtype=0", + path: "/cam/realmonitor?channel=1&subtype=0", + type: "FFMPEG", + resolution: "1920x1080", + codec: "H.265", + fps: 30, + bitrate: 5120000, + has_audio: true + }, + { + url: "rtsp://192.168.1.100:554/cam/realmonitor?channel=1&subtype=1", + path: "/cam/realmonitor?channel=1&subtype=1", + type: "FFMPEG", + resolution: "640x480", + codec: "H.264", + fps: 10, + bitrate: 512000, + has_audio: false + }, + // MJPEG { url: "http://192.168.1.100/video.mjpg", path: "/video.mjpg", type: "MJPEG", - resolution: "1280x720", + resolution: "1920x1080", codec: "MJPEG", fps: 10, - bitrate: 2048000, + bitrate: 3072000, has_audio: false }, + // JPEG #4 + { + url: "http://192.168.1.100/Streaming/channels/1/picture", + path: "/Streaming/channels/1/picture", + type: "JPEG", + resolution: "1920x1080", + codec: "JPEG", + fps: 1, + bitrate: 0, + has_audio: false + }, + // HLS { url: "http://192.168.1.100/stream/live.m3u8", path: "/stream/live.m3u8", @@ -42,16 +172,18 @@ export class MockStreamAPI { bitrate: 3072000, has_audio: true }, + // HTTP Video { url: "http://192.168.1.100/videostream.cgi?user=admin&pwd=12345", path: "/videostream.cgi?user=admin&pwd=12345", type: "HTTP_VIDEO", - resolution: "1280x960", + resolution: "1920x1080", codec: "H.264", fps: 20, - bitrate: 2048000, + bitrate: 2560000, has_audio: false }, + // BUBBLE { url: "bubble://192.168.1.100:34567/bubble/live?ch=0&stream=0", path: "/bubble/live?ch=0&stream=0", @@ -59,33 +191,75 @@ export class MockStreamAPI { resolution: "1920x1080", codec: "H.264", fps: 25, - bitrate: 3072000, + bitrate: 3584000, + has_audio: true + }, + // JPEG #5 + { + url: "http://192.168.1.100/tmpfs/auto.jpg", + path: "/tmpfs/auto.jpg", + type: "JPEG", + resolution: "1920x1080", + codec: "JPEG", + fps: 1, + bitrate: 0, + has_audio: false + }, + // Additional RTSP + { + url: "rtsp://192.168.1.100:554/h264_stream", + path: "/h264_stream", + type: "FFMPEG", + resolution: "1920x1080", + codec: "H.264", + fps: 30, + bitrate: 4096000, has_audio: true }, { - url: "rtsp://192.168.1.100:554/cam/realmonitor?channel=1&subtype=0", - path: "/cam/realmonitor?channel=1&subtype=0", - type: "ONVIF", - resolution: "2560x1440", + url: "rtsp://192.168.1.100:554/av0_0", + path: "/av0_0", + type: "FFMPEG", + resolution: "1920x1080", + codec: "H.264", + fps: 25, + bitrate: 3840000, + has_audio: true + }, + { + url: "rtsp://192.168.1.100:554/av0_1", + path: "/av0_1", + type: "FFMPEG", + resolution: "640x480", + codec: "H.264", + fps: 10, + bitrate: 512000, + has_audio: false + }, + { + url: "rtsp://192.168.1.100:554/unicast/c1/s0/live", + path: "/unicast/c1/s0/live", + type: "FFMPEG", + resolution: "1920x1080", codec: "H.265", - fps: 30, - bitrate: 6144000, + fps: 25, + bitrate: 4608000, has_audio: true } ]; } discover(request, callbacks) { - const totalToScan = 150; + const totalToScan = 450; const streamsToFind = this.mockStreams; let tested = 0; let found = 0; const startTime = Date.now(); - // Simulate progressive discovery - const interval = setInterval(() => { - const increment = Math.floor(Math.random() * 8) + 3; + // Simulate progressive discovery - 1 stream per second + const progressInterval = setInterval(() => { + const increment = Math.floor(Math.random() * 15) + 10; tested = Math.min(tested + increment, totalToScan); const remaining = totalToScan - tested; @@ -98,33 +272,9 @@ export class MockStreamAPI { }); } - // Randomly find streams - if (found < streamsToFind.length && Math.random() > 0.6) { - const stream = streamsToFind[found]; - found++; - - if (callbacks.onStreamFound) { - callbacks.onStreamFound({ - stream: stream - }); - } - } - // Complete when done if (tested >= totalToScan) { - clearInterval(interval); - - // Send any remaining streams - while (found < streamsToFind.length) { - const stream = streamsToFind[found]; - found++; - - if (callbacks.onStreamFound) { - callbacks.onStreamFound({ - stream: stream - }); - } - } + clearInterval(progressInterval); const duration = (Date.now() - startTime) / 1000; @@ -136,7 +286,23 @@ export class MockStreamAPI { }); } } - }, 400); + }, 300); + + // Find streams at ~1 per second + const streamInterval = setInterval(() => { + if (found < streamsToFind.length) { + const stream = streamsToFind[found]; + found++; + + if (callbacks.onStreamFound) { + callbacks.onStreamFound({ + stream: stream + }); + } + } else { + clearInterval(streamInterval); + } + }, 1000); } close() { diff --git a/webui/web/js/ui/stream-list.js b/webui/web/js/ui/stream-list.js index 5d51a94..694a5d1 100644 --- a/webui/web/js/ui/stream-list.js +++ b/webui/web/js/ui/stream-list.js @@ -4,19 +4,277 @@ export class StreamList { this.streams = []; this.onUseCallback = null; this.expandedIndex = null; + // Track collapsed state for groups and subgroups + this.collapsedGroups = new Set(); + this.collapsedSubgroups = new Set(); + // Selection mode: 'main' or 'sub' + this.selectionMode = 'main'; + // Flag to apply smart defaults on first render after reset + this.needsSmartDefaults = true; + } + + /** + * Set selection mode and apply smart defaults for collapsed state + * Only resets collapsed state when mode actually changes + */ + setSelectionMode(mode) { + if (this.selectionMode === mode) return; + + this.selectionMode = mode; + this.applySmartDefaults(); + } + + /** + * Apply smart collapsed defaults based on current selection mode and available streams + */ + applySmartDefaults() { + // Get current stream classification + const recommended = this.streams.filter(s => this.isRecommended(s)); + const { main, sub, other } = this.classifyRecommendedStreams( + recommended.map((stream, i) => ({ stream, index: i })) + ); + + // Reset all collapsed states + this.collapsedGroups.clear(); + this.collapsedSubgroups.clear(); + + if (this.selectionMode === 'main') { + // Main mode: show Main, collapse Sub/Other/Alternative + if (main.length > 0) { + // Has main streams - collapse everything except Main + this.collapsedGroups.add('alternative'); + this.collapsedSubgroups.add('recommended-sub'); + this.collapsedSubgroups.add('recommended-other'); + } + // If no main streams - leave everything open + } else { + // Sub mode: show Sub, collapse Main/Other/Alternative + if (sub.length > 0) { + // Has sub streams - collapse everything except Sub + this.collapsedGroups.add('alternative'); + this.collapsedSubgroups.add('recommended-main'); + this.collapsedSubgroups.add('recommended-other'); + } + // If no sub streams - leave everything open + } + } + + // Stream types considered "recommended" (standard video streams) + static RECOMMENDED_TYPES = ['FFMPEG', 'ONVIF']; + + // Minimum width threshold for Main streams (HD quality) + static MIN_MAIN_WIDTH = 720; + + // Minimum gap between resolutions to split Main/Sub + static MIN_GAP_FOR_SPLIT = 400; + + isRecommended(stream) { + return StreamList.RECOMMENDED_TYPES.includes(stream.type); + } + + /** + * Parse resolution string "1920x1080" to width number + * Returns null if resolution is missing or invalid + */ + parseResolutionWidth(resolution) { + if (!resolution) return null; + const match = resolution.match(/^(\d+)x(\d+)$/); + if (!match) return null; + return parseInt(match[1], 10); + } + + /** + * Classify recommended streams into Main/Sub/Other using clustering algorithm + * + * Algorithm: + * 1. Streams with width >= 720 are candidates for Main + * 2. Streams with width < 720 go to Sub + * 3. Streams without resolution go to Other + * 4. Among Main candidates, find max gap between sorted resolutions + * 5. If gap > 400px, split into Main (higher) and Sub (lower) + */ + classifyRecommendedStreams(items) { + const main = []; + const sub = []; + const other = []; + + // First pass: separate by resolution availability and threshold + const mainCandidates = []; // width >= 720 + + items.forEach(item => { + const width = this.parseResolutionWidth(item.stream.resolution); + + if (width === null) { + other.push(item); + } else if (width < StreamList.MIN_MAIN_WIDTH) { + sub.push(item); + } else { + mainCandidates.push({ ...item, width }); + } + }); + + // If no main candidates or only one, no need to cluster + if (mainCandidates.length <= 1) { + mainCandidates.forEach(item => main.push({ stream: item.stream, index: item.index })); + return { main, sub, other }; + } + + // Sort candidates by width descending + mainCandidates.sort((a, b) => b.width - a.width); + + // Find the largest gap between adjacent resolutions + let maxGap = 0; + let splitIndex = -1; + + for (let i = 0; i < mainCandidates.length - 1; i++) { + const gap = mainCandidates[i].width - mainCandidates[i + 1].width; + if (gap > maxGap) { + maxGap = gap; + splitIndex = i; + } + } + + // If max gap is significant, split into Main and Sub + if (maxGap > StreamList.MIN_GAP_FOR_SPLIT && splitIndex >= 0) { + mainCandidates.forEach((item, i) => { + const cleanItem = { stream: item.stream, index: item.index }; + if (i <= splitIndex) { + main.push(cleanItem); + } else { + sub.push(cleanItem); + } + }); + } else { + // All candidates stay in Main + mainCandidates.forEach(item => { + main.push({ stream: item.stream, index: item.index }); + }); + } + + return { main, sub, other }; } render(streams, onUseCallback) { this.streams = streams; this.onUseCallback = onUseCallback; - // Render stream items - this.listContainer.innerHTML = streams.map((stream, index) => this.renderItem(stream, index)).join(''); + // Apply smart defaults on first render after reset + if (this.needsSmartDefaults && streams.length > 0) { + this.needsSmartDefaults = false; + this.applySmartDefaults(); + } + + // Split streams into groups while preserving original indices + const recommended = []; + const alternative = []; + + streams.forEach((stream, index) => { + if (this.isRecommended(stream)) { + recommended.push({ stream, index }); + } else { + alternative.push({ stream, index }); + } + }); + + // Render only non-empty groups + let html = ''; + if (recommended.length > 0) { + html += this.renderRecommendedGroup(recommended); + } + if (alternative.length > 0) { + html += this.renderGroup('Alternative', alternative, 'alternative'); + } + this.listContainer.innerHTML = html; // Attach event listeners this.attachEventListeners(); } + /** + * Render Recommended group with Main/Sub/Other subgroups + */ + renderRecommendedGroup(items) { + const { main, sub, other } = this.classifyRecommendedStreams(items); + const totalCount = items.length; + const isCollapsed = this.collapsedGroups.has('recommended'); + + let subgroupsHtml = ''; + + if (main.length > 0) { + subgroupsHtml += this.renderSubgroup('Main', main, 'recommended'); + } + if (sub.length > 0) { + subgroupsHtml += this.renderSubgroup('Sub', sub, 'recommended'); + } + if (other.length > 0) { + subgroupsHtml += this.renderSubgroup('Other', other, 'recommended'); + } + + return ` + + `; + } + + /** + * Render a subgroup (Main/Sub/Other) within Recommended + */ + renderSubgroup(title, items, parentGroup) { + const subgroupKey = `${parentGroup}-${title.toLowerCase()}`; + const isCollapsed = this.collapsedSubgroups.has(subgroupKey); + + return ` +
+
+ + ${title} + (${items.length}) +
+
+ ${items.map(({ stream, index }) => this.renderItem(stream, index)).join('')} +
+
+ `; + } + + renderGroup(title, items, groupClass) { + const count = items.length; + const isCollapsed = this.collapsedGroups.has(groupClass); + + return ` +
+
+ + ${title} + (${count}) +
+
+ ${items.map(({ stream, index }) => this.renderItem(stream, index)).join('')} +
+
+ `; + } + renderItem(stream, index) { const icon = this.getStreamIcon(stream.type); const isExpanded = this.expandedIndex === index; @@ -225,7 +483,28 @@ export class StreamList { } attachEventListeners() { - // Click on header to toggle + // Group header toggle (Recommended, Alternative) + this.listContainer.querySelectorAll('.stream-group-header').forEach(header => { + header.addEventListener('click', (e) => { + const groupKey = header.dataset.group; + if (groupKey) { + this.toggleGroup(groupKey); + } + }); + }); + + // Subgroup header toggle (Main, Sub, Other) + this.listContainer.querySelectorAll('.stream-subgroup-header').forEach(header => { + header.addEventListener('click', (e) => { + e.stopPropagation(); // Don't bubble to group header + const subgroupKey = header.dataset.subgroup; + if (subgroupKey) { + this.toggleSubgroup(subgroupKey); + } + }); + }); + + // Click on stream item header to toggle details this.listContainer.querySelectorAll('.stream-item-header').forEach(header => { header.addEventListener('click', (e) => { // Don't toggle if clicking "Use Stream" button @@ -250,6 +529,24 @@ export class StreamList { }); } + toggleGroup(groupKey) { + if (this.collapsedGroups.has(groupKey)) { + this.collapsedGroups.delete(groupKey); + } else { + this.collapsedGroups.add(groupKey); + } + this.render(this.streams, this.onUseCallback); + } + + toggleSubgroup(subgroupKey) { + if (this.collapsedSubgroups.has(subgroupKey)) { + this.collapsedSubgroups.delete(subgroupKey); + } else { + this.collapsedSubgroups.add(subgroupKey); + } + this.render(this.streams, this.onUseCallback); + } + toggleExpand(index) { if (this.expandedIndex === index) { // Collapse if already expanded From e9dc04178e29c3365413bcd1189c3546bbbaaae5 Mon Sep 17 00:00:00 2001 From: eduard256 Date: Thu, 11 Dec 2025 16:34:05 +0000 Subject: [PATCH 3/4] Fix SSE real-time streaming in Home Assistant Ingress mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add padding to overcome aiohttp 64KB buffer in HA Supervisor. Problem: - HA Supervisor uses aiohttp with 64KB StreamResponse buffer - Small SSE events (~200-500 bytes) were buffered until connection closed - Users saw all streams appear at once instead of real-time updates Solution: - Detect Ingress mode via X-Ingress-Path header - Add 64KB SSE comment padding to fill proxy buffers - Increase progress interval to 3 sec in Ingress mode (reduce traffic) - Normal mode (Docker/direct) unchanged - works exactly as before Traffic impact: - Normal mode: ~17KB per scan (unchanged) - Ingress mode: ~2-3MB per scan (acceptable for real-time updates) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- internal/camera/discovery/scanner.go | 11 ++++- pkg/sse/sse.go | 72 ++++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/internal/camera/discovery/scanner.go b/internal/camera/discovery/scanner.go index df2fff9..81b4bb7 100644 --- a/internal/camera/discovery/scanner.go +++ b/internal/camera/discovery/scanner.go @@ -411,7 +411,14 @@ func (s *Scanner) testStreamsConcurrently(ctx context.Context, streams []models. defer cancelProgress() go func() { - ticker := time.NewTicker(1 * time.Second) + // Use longer interval for Ingress mode to reduce traffic (padding is ~64KB per event) + // Normal mode: 1 second, Ingress mode: 3 seconds + progressInterval := 1 * time.Second + if streamWriter.IsIngress() { + progressInterval = 3 * time.Second + } + + ticker := time.NewTicker(progressInterval) defer ticker.Stop() for { @@ -419,7 +426,7 @@ func (s *Scanner) testStreamsConcurrently(ctx context.Context, streams []models. case <-progressCtx.Done(): return case <-ticker.C: - // Send progress every second to prevent WriteTimeout + // Send progress to prevent WriteTimeout and show scanning activity _ = streamWriter.SendJSON("progress", models.ProgressMessage{ Tested: int(atomic.LoadInt32(&tested)), Found: int(atomic.LoadInt32(&found)), diff --git a/pkg/sse/sse.go b/pkg/sse/sse.go index 9dd79fa..de9985a 100644 --- a/pkg/sse/sse.go +++ b/pkg/sse/sse.go @@ -5,9 +5,20 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" ) +const ( + // IngressPaddingSize is the padding size for Home Assistant Ingress mode. + // HA Supervisor uses aiohttp with 64KB buffer for StreamResponse. + // We need to fill this buffer to force immediate delivery of SSE events. + IngressPaddingSize = 64 * 1024 // 64KB + + // IngressHeader is the header that Home Assistant Ingress adds to requests + IngressHeader = "X-Ingress-Path" +) + // Event represents a Server-Sent Event type Event struct { ID string @@ -253,8 +264,9 @@ func generateClientID() string { // StreamWriter provides a simple interface for writing SSE events type StreamWriter struct { - client *Client - server *Server + client *Client + server *Server + isIngress bool // True when running through Home Assistant Ingress proxy } // NewStreamWriter creates a new stream writer for a client @@ -275,6 +287,9 @@ func (s *Server) NewStreamWriter(w http.ResponseWriter, r *http.Request) (*Strea // Send initial flush to establish connection flusher.Flush() + // Detect Home Assistant Ingress mode by checking for X-Ingress-Path header + isIngress := r.Header.Get(IngressHeader) != "" + // Create client ctx, cancel := context.WithCancel(r.Context()) client := &Client{ @@ -287,8 +302,9 @@ func (s *Server) NewStreamWriter(w http.ResponseWriter, r *http.Request) (*Strea } return &StreamWriter{ - client: client, - server: s, + client: client, + server: s, + isIngress: isIngress, }, nil } @@ -304,7 +320,48 @@ func (sw *StreamWriter) SendEvent(eventType string, data interface{}) error { return fmt.Errorf("response does not support flushing") } - return sw.server.writeEvent(sw.client.Response, flusher, event) + // Use Ingress-aware write method + return sw.writeEventWithIngress(sw.client.Response, flusher, event) +} + +// writeEventWithIngress writes an event and adds padding for Ingress mode +func (sw *StreamWriter) writeEventWithIngress(w http.ResponseWriter, flusher http.Flusher, event Event) error { + // Write the event using standard method + if err := sw.server.writeEvent(w, flusher, event); err != nil { + return err + } + + // In Ingress mode, add padding to fill the 64KB buffer and force immediate delivery + if sw.isIngress { + if err := sw.writePadding(w, flusher); err != nil { + return err + } + } + + return nil +} + +// writePadding writes SSE comment padding to fill proxy buffers. +// SSE comments (lines starting with ':') are ignored by clients. +func (sw *StreamWriter) writePadding(w http.ResponseWriter, flusher http.Flusher) error { + // Create padding using SSE comments which are ignored by clients + // Each line is ": " + padding content + "\n" + // We need ~64KB to fill the aiohttp StreamResponse buffer + const lineSize = 1024 // 1KB per line + const numLines = 64 // 64 lines = 64KB + + paddingLine := ": " + strings.Repeat(".", lineSize-4) + "\n" // -4 for ": " and "\n" + + for i := 0; i < numLines; i++ { + if _, err := fmt.Fprint(w, paddingLine); err != nil { + return err + } + } + + // Flush the padding + flusher.Flush() + + return nil } // SendJSON sends JSON data as an event @@ -312,6 +369,11 @@ func (sw *StreamWriter) SendJSON(eventType string, v interface{}) error { return sw.SendEvent(eventType, v) } +// IsIngress returns true if running through Home Assistant Ingress proxy +func (sw *StreamWriter) IsIngress() bool { + return sw.isIngress +} + // SendMessage sends a simple message func (sw *StreamWriter) SendMessage(message string) error { return sw.SendEvent("message", map[string]string{"message": message}) From 787919d20b75fb43d373886537ba4de08284aeda Mon Sep 17 00:00:00 2001 From: eduard256 Date: Thu, 11 Dec 2025 16:40:31 +0000 Subject: [PATCH 4/4] Release v1.0.9: Fix SSE real-time streaming in Home Assistant Ingress mode --- CHANGELOG.md | 12 ++++++++++++ cmd/strix/main.go | 2 +- webui/package.json | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a01af1..9c54575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.9] - 2025-12-11 + +### Fixed +- Fixed real-time SSE streaming in Home Assistant Ingress mode +- SSE events now arrive immediately instead of being buffered until completion + +### Technical +- Added automatic detection of Home Assistant Ingress via X-Ingress-Path header +- Implemented 64KB padding for SSE events to overcome aiohttp buffer in HA Supervisor +- Adjusted progress update interval to 3 seconds in Ingress mode to reduce traffic +- Normal mode (Docker/direct access) remains unchanged + ## [1.0.8] - 2025-11-26 ### Changed diff --git a/cmd/strix/main.go b/cmd/strix/main.go index 0ce8160..248a4a0 100644 --- a/cmd/strix/main.go +++ b/cmd/strix/main.go @@ -20,7 +20,7 @@ import ( const ( // Version is the application version - Version = "1.0.7" + Version = "1.0.9" // Banner is the application banner Banner = ` diff --git a/webui/package.json b/webui/package.json index cce0065..a1e8b57 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "webui", - "version": "1.0.4", + "version": "1.0.9", "type": "module", "description": "", "main": "index.js",