+
ID: ${streamId || 'n/a'}
-
+
${streamBtn}
${goLiveBtn}
+ ${stopBtn}
${streamId ? ` ` : ''}
-
+
`;
});
@@ -8613,6 +8871,271 @@
Add Community No
.replace(/'/g, ''');
}
+function _escapeAttr(value) {
+ return _escapeHtml(value).replace(/`/g, '`');
+}
+
+window._streamAttachmentContexts = window._streamAttachmentContexts || {};
+
+const STREAM_PROFILE_CONFIG = {
+ audio_ops: {
+ id: 'audio_ops',
+ label: 'Audio briefing',
+ subtitle: 'Operator voice, room audio, radio, or ambient capture.',
+ stream_kind: 'media',
+ media_kind: 'audio',
+ domain: 'media',
+ operator_profile: 'briefing',
+ viewer_layout: 'focus',
+ },
+ video_ops: {
+ id: 'video_ops',
+ label: 'Video watch',
+ subtitle: 'Camera or screen watch with live viewer playback.',
+ stream_kind: 'media',
+ media_kind: 'video',
+ domain: 'media',
+ operator_profile: 'watch',
+ viewer_layout: 'cinema',
+ },
+ telemetry_sensor: {
+ id: 'telemetry_sensor',
+ label: 'Telemetry feed',
+ subtitle: 'Sensor, automation, robotics, or machine event stream.',
+ stream_kind: 'telemetry',
+ media_kind: 'data',
+ domain: 'sensor',
+ operator_profile: 'monitor',
+ viewer_layout: 'dense',
+ },
+};
+
+function _streamScenarioLabel(domain, streamKind, mediaKind) {
+ const normalizedDomain = String(domain || '').trim().toLowerCase();
+ if (normalizedDomain) {
+ if (normalizedDomain === 'sensor') return 'Sensor / telemetry';
+ if (normalizedDomain === 'humanoid') return 'Humanoid session';
+ if (normalizedDomain === 'robotics') return 'Robotics control';
+ if (normalizedDomain === 'automation') return 'Automation runtime';
+ if (normalizedDomain === 'media') return mediaKind === 'video' ? 'Video session' : 'Audio session';
+ }
+ return streamKind === 'telemetry' ? 'Telemetry feed' : (mediaKind === 'video' ? 'Video session' : 'Audio session');
+}
+
+function _renderStreamCreateHealth(payload) {
+ const health = (payload && payload.health) || {};
+ const capabilities = (payload && payload.capabilities) || {};
+ const ffmpegState = health.ffmpeg_found ? 'Ready' : 'Missing';
+ const ffprobeState = health.ffprobe_found ? 'Ready' : 'Missing';
+ const latencyMode = health.latency_mode_supported || 'hls';
+ const domains = Array.isArray(capabilities.domain_options) ? capabilities.domain_options.join(', ') : 'media, sensor, humanoid, robotics, automation';
+ return `
+
+
Host readiness
+
+
+
Stream runtime
+
${health.stream_manager_ready ? 'Ready' : 'Unavailable'}
+
+
+
Latency mode
+
${_escapeHtml(latencyMode)}
+
+
+
ffmpeg
+
${_escapeHtml(ffmpegState)}
+
+
+
ffprobe
+
${_escapeHtml(ffprobeState)}
+
+
+
Prepared domains: ${_escapeHtml(domains)}. Media streaming is the primary scope now; telemetry and device-facing streams are structured for the next stage.
+
+ `;
+}
+
+function _setActiveStreamProfile(profileId) {
+ const profile = STREAM_PROFILE_CONFIG[profileId] || STREAM_PROFILE_CONFIG.audio_ops;
+ document.querySelectorAll('.stream-create-profile').forEach((el) => {
+ el.classList.toggle('active', el.dataset.profileId === profile.id);
+ });
+ const streamKindEl = document.getElementById('stream-create-kind');
+ const mediaKindEl = document.getElementById('stream-create-media-kind');
+ const domainEl = document.getElementById('stream-create-domain');
+ const operatorProfileEl = document.getElementById('stream-create-operator-profile');
+ const viewerLayoutEl = document.getElementById('stream-create-viewer-layout');
+ const titleEl = document.getElementById('stream-create-title');
+ const helperEl = document.getElementById('stream-create-helper');
+ if (streamKindEl) streamKindEl.value = profile.stream_kind;
+ if (mediaKindEl) mediaKindEl.value = profile.media_kind;
+ if (domainEl && !domainEl.value) domainEl.value = profile.domain;
+ if (operatorProfileEl) operatorProfileEl.value = profile.operator_profile;
+ if (viewerLayoutEl) viewerLayoutEl.value = profile.viewer_layout;
+ if (helperEl) helperEl.textContent = profile.subtitle;
+ if (titleEl && !titleEl.value.trim()) {
+ const channelLabelEl = document.getElementById('current-channel-name');
+ const channelLabel = channelLabelEl ? channelLabelEl.textContent.trim() : String(currentChannelId || '');
+ if (profile.id === 'audio_ops') titleEl.value = `Live audio feed ${channelLabel}`.trim();
+ if (profile.id === 'video_ops') titleEl.value = `Live video watch ${channelLabel}`.trim();
+ if (profile.id === 'telemetry_sensor') titleEl.value = `Telemetry feed ${channelLabel}`.trim();
+ }
+}
+
+async function openStreamCreateModal(profileId = 'audio_ops') {
+ if (!currentChannelId) {
+ showAlert('Select a channel before creating a stream card', 'warning');
+ return;
+ }
+ let healthPayload = null;
+ try {
+ healthPayload = await apiCall('/ajax/streams/health', { method: 'GET' });
+ } catch (e) {
+ healthPayload = null;
+ }
+
+ const existing = document.getElementById('streamCreateModal');
+ if (existing) existing.remove();
+
+ const profilesHtml = Object.values(STREAM_PROFILE_CONFIG).map((profile) => `
+
+ ${_escapeHtml(profile.label)}
+ ${_escapeHtml(profile.subtitle)}
+
+ `).join('');
+
+ const modalHtml = `
+
+ `;
+
+ document.body.insertAdjacentHTML('beforeend', modalHtml);
+ const modalEl = document.getElementById('streamCreateModal');
+ const modal = new bootstrap.Modal(modalEl);
+ modalEl.addEventListener('hidden.bs.modal', () => modalEl.remove());
+ modal.show();
+ _setActiveStreamProfile(profileId);
+}
+
+async function submitStreamCreateModal() {
+ const title = String((document.getElementById('stream-create-title') || {}).value || '').trim();
+ const description = String((document.getElementById('stream-create-description') || {}).value || '').trim();
+ const streamKind = String((document.getElementById('stream-create-kind') || {}).value || 'media').trim().toLowerCase();
+ const mediaKind = String((document.getElementById('stream-create-media-kind') || {}).value || 'audio').trim().toLowerCase();
+ const streamDomain = String((document.getElementById('stream-create-domain') || {}).value || 'media').trim().toLowerCase();
+ const operatorProfile = String((document.getElementById('stream-create-operator-profile') || {}).value || '').trim().toLowerCase();
+ const viewerLayout = String((document.getElementById('stream-create-viewer-layout') || {}).value || '').trim().toLowerCase();
+ const autoPost = !!((document.getElementById('stream-create-auto-post') || {}).checked);
+ const startNow = !!((document.getElementById('stream-create-start-now') || {}).checked);
+ const relayAllowed = !!((document.getElementById('stream-create-relay') || {}).checked);
+
+ if (!title) {
+ showAlert('Stream title is required', 'warning');
+ return;
+ }
+
+ try {
+ const payload = {
+ channel_id: currentChannelId,
+ title,
+ description,
+ stream_kind: streamKind,
+ media_kind: mediaKind,
+ protocol: streamKind === 'telemetry' ? 'events-json' : 'hls',
+ relay_allowed: relayAllowed,
+ auto_post: autoPost,
+ start_now: startNow,
+ post_content: streamKind === 'telemetry'
+ ? `Telemetry feed started: ${title}`
+ : (mediaKind === 'video' ? `Live video stream started: ${title}` : `Live audio stream started: ${title}`),
+ stream_domain: streamDomain,
+ operator_profile: operatorProfile,
+ viewer_layout: viewerLayout,
+ };
+ const res = await apiCall('/ajax/streams', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+ if (!res || !res.success || !res.stream) {
+ throw new Error((res && (res.error || res.message)) || 'Failed to create stream');
+ }
+ const modalEl = document.getElementById('streamCreateModal');
+ if (modalEl) {
+ const modal = bootstrap.Modal.getInstance(modalEl);
+ if (modal) modal.hide();
+ }
+ showAlert(`Stream "${title}" created`, 'success');
+ requestChannelThreadRefresh({ forceScroll: true });
+ } catch (err) {
+ const msg = err && err.message ? err.message : 'Failed to create stream';
+ showAlert(msg, 'danger');
+ }
+}
+
async function _renderTelemetryStreamPanel(streamId, slot, sessionPayload) {
const eventsUrl = String((sessionPayload && sessionPayload.playback_url) || '');
if (!eventsUrl) {
@@ -8679,6 +9202,212 @@ Add Community No
`;
}
+function _formatStreamTimestamp(value) {
+ if (!value) return 'n/a';
+ try {
+ const dt = new Date(value);
+ if (Number.isNaN(dt.getTime())) return String(value);
+ return dt.toLocaleString();
+ } catch (_) {
+ return String(value);
+ }
+}
+
+function _normalizeStreamStatus(value) {
+ const normalized = String(value || '').trim().toLowerCase();
+ if (normalized === 'live' || normalized === 'stopped') return normalized;
+ return 'created';
+}
+
+function _streamStatusLabel(status) {
+ const normalized = _normalizeStreamStatus(status);
+ if (normalized === 'live') return 'LIVE';
+ if (normalized === 'stopped') return 'Stopped';
+ return 'Preparing';
+}
+
+function _buildStreamWorkspaceShell(streamId, slot, streamRow, context, sessionPayload, setupPayload, mediaKind, streamKind) {
+ const ids = {
+ viewer: `${slot.id}-viewer`,
+ details: `${slot.id}-details`,
+ owner: `${slot.id}-owner-tools`,
+ };
+ const metadata = streamRow && typeof streamRow.metadata === 'object' && streamRow.metadata
+ ? streamRow.metadata
+ : ((context && typeof context.metadata === 'object' && context.metadata) || {});
+ const title = _escapeHtml((streamRow && streamRow.title) || context.title || 'Live stream');
+ const description = _escapeHtml((streamRow && streamRow.description) || context.description || '');
+ const domain = String(metadata.stream_domain || (streamKind === 'telemetry' ? 'sensor' : 'media')).toLowerCase();
+ const scenario = _streamScenarioLabel(domain, streamKind, mediaKind);
+ const statusRaw = _normalizeStreamStatus((streamRow && streamRow.status) || context.status || 'created');
+ const status = _escapeHtml(statusRaw);
+ const statusLabel = _escapeHtml(_streamStatusLabel(statusRaw));
+ const protocol = _escapeHtml(String((streamRow && streamRow.protocol) || context.protocol || (streamKind === 'telemetry' ? 'events-json' : 'hls')));
+ const createdBy = String((streamRow && streamRow.created_by) || context.created_by || '');
+ const isOwner = !!createdBy && createdBy === currentUserId;
+ const playbackUrl = _escapeAttr((sessionPayload && sessionPayload.playback_url) || '');
+ const sessionExpiresAt = _formatStreamTimestamp(sessionPayload && sessionPayload.expires_at);
+ const layoutLabel = _escapeHtml(String(metadata.viewer_layout || 'adaptive'));
+ const operatorLabel = _escapeHtml(String(metadata.operator_profile || 'general'));
+
+ let detailsHtml = `
+
+
Stream status
+
+
+
Scenario
+
${_escapeHtml(scenario)}
+
+
+
Status
+
${statusLabel}
+
+
+
Transport
+
${protocol}
+
+
+
Viewer session
+
${_escapeHtml(sessionExpiresAt)}
+
+
+
Layout
+
${layoutLabel}
+
+
+
Operator profile
+
${operatorLabel}
+
+
+
+ `;
+
+ if (isOwner && setupPayload && setupPayload.success && setupPayload.setup) {
+ const setup = setupPayload.setup || {};
+ const preflight = setup.preflight || {};
+ const warnings = Array.isArray(preflight.warnings) ? preflight.warnings : [];
+ const warningHtml = warnings.length
+ ? `${warnings.map((item) => `
${_escapeHtml(item.message || item.code || 'Warning')}
`).join('')}
`
+ : 'No current host warnings.
';
+ detailsHtml += `
+
+
Operator toolkit
+
+
+
Ingest token
+
${_escapeHtml(_formatStreamTimestamp(setup.ingest_expires_at))}
+
+
+
View token
+
${_escapeHtml(_formatStreamTimestamp(setup.view_expires_at))}
+
+
+
ffmpeg
+
${preflight.ffmpeg_found ? 'Ready' : 'Missing'}
+
+
+
Proxy mode
+
${_escapeHtml(String(preflight.remote_proxy_mode || 'local'))}
+
+
+ ${warningHtml}
+
+ Show ingest command
+ ${_escapeHtml((setup.commands || {}).posix || '')}
+
+
+
+ `;
+ }
+
+ const goLiveAction = isOwner && streamKind !== 'telemetry'
+ ? ` Go Live `
+ : '';
+ const stopAction = isOwner
+ ? ` Stop stream `
+ : '';
+
+ slot.style.display = 'block';
+ slot.innerHTML = `
+
+
+
+
+
+
${title}
+
${description || _escapeHtml(scenario)}
+
+ ${streamKind === 'telemetry' ? 'Telemetry' : (mediaKind === 'video' ? 'Video' : 'Audio')}
+ ${_escapeHtml(scenario)}
+ ${statusLabel}
+
+
+
+
Refresh
+ ${goLiveAction}
+ ${stopAction}
+
Copy ID
+ ${playbackUrl ? `
Raw URL` : ''}
+
+
+
+
+
${streamKind === 'telemetry' ? 'Live feed' : 'Live player'}
+
Joining stream…
+
+
+
${detailsHtml}
+
+ `;
+ return {
+ viewerSlot: document.getElementById(ids.viewer),
+ ownerSlot: document.getElementById(ids.owner),
+ isOwner,
+ };
+}
+
+function _syncStreamCardStatus(slot, streamRow, context) {
+ if (!slot || !streamRow) return;
+ const card = slot.closest('.stream-card');
+ if (!card) return;
+ const normalizedStatus = _normalizeStreamStatus((streamRow && streamRow.status) || '');
+ if (!normalizedStatus) return;
+ if (context && typeof context === 'object') {
+ context.status = normalizedStatus;
+ }
+ const statusLabel = _streamStatusLabel(normalizedStatus);
+ const cardChip = card.querySelector('[data-stream-status-chip="1"]');
+ if (cardChip) {
+ cardChip.setAttribute('data-state', normalizedStatus);
+ cardChip.textContent = statusLabel;
+ }
+ const workspaceChip = slot.querySelector('.stream-workspace-shell [data-stream-status-chip="1"]');
+ if (workspaceChip) {
+ workspaceChip.setAttribute('data-state', normalizedStatus);
+ workspaceChip.textContent = statusLabel;
+ }
+ const workspaceValue = slot.querySelector('[data-stream-status-value="1"]');
+ if (workspaceValue) {
+ workspaceValue.textContent = statusLabel;
+ }
+}
+
+async function _setStreamLifecycle(streamId, action, slotId) {
+ const res = await apiCall(`/ajax/streams/${encodeURIComponent(streamId)}/${action}`, {
+ method: 'POST',
+ body: JSON.stringify({}),
+ });
+ if (!res || !res.success || !res.stream) {
+ throw new Error((res && (res.error || res.message)) || `Unable to ${action} stream`);
+ }
+ const slot = slotId ? document.getElementById(slotId) : null;
+ const context = slotId ? ((window._streamAttachmentContexts && window._streamAttachmentContexts[slotId]) || null) : null;
+ if (slot) {
+ _syncStreamCardStatus(slot, res.stream, context);
+ }
+ return res.stream;
+}
+
// Lazily loads hls.js from CDN on first call; subsequent calls reuse the promise.
let _hlsJsLoadPromise = null;
function _loadHlsJs() {
@@ -8704,6 +9433,18 @@ Add Community No
const slot = document.getElementById(slotId);
if (!slot) return;
try {
+ const context = (window._streamAttachmentContexts && window._streamAttachmentContexts[slotId]) || {};
+ let streamRow = null;
+ try {
+ const detailRes = await apiCall(`/ajax/streams/${encodeURIComponent(streamId)}`, { method: 'GET' });
+ if (detailRes && detailRes.success && detailRes.stream) {
+ streamRow = detailRes.stream;
+ _syncStreamCardStatus(slot, streamRow, context);
+ }
+ } catch (_) {}
+
+ const currentStreamKind = String(streamKind || (streamRow && streamRow.stream_kind) || context.stream_kind || 'media').toLowerCase();
+ const currentMediaKind = String(mediaKind || (streamRow && streamRow.media_kind) || context.media_kind || 'audio').toLowerCase();
const res = await apiCall(`/ajax/streams/${encodeURIComponent(streamId)}/session`, {
method: 'POST',
body: JSON.stringify({}),
@@ -8711,24 +9452,49 @@ Add Community No
if (!res || !res.success || !res.playback_url) {
throw new Error((res && (res.error || res.message)) || 'Unable to join stream');
}
+ streamRow = res.stream || streamRow || context;
+ _syncStreamCardStatus(slot, streamRow, context);
+ let setupPayload = null;
+ if (String((streamRow && streamRow.created_by) || context.created_by || '') === currentUserId) {
+ try {
+ setupPayload = await apiCall(`/ajax/streams/${encodeURIComponent(streamId)}/setup`, {
+ method: 'POST',
+ body: JSON.stringify({}),
+ });
+ } catch (_) {
+ setupPayload = null;
+ }
+ }
- const normalizedKind = String(streamKind || '').toLowerCase();
- if (normalizedKind === 'telemetry' || String(mediaKind || '').toLowerCase() === 'data') {
- await _renderTelemetryStreamPanel(streamId, slot, res);
+ const workspace = _buildStreamWorkspaceShell(
+ streamId,
+ slot,
+ streamRow || {},
+ context || {},
+ res,
+ setupPayload,
+ currentMediaKind,
+ currentStreamKind,
+ );
+ const playerSlot = workspace.viewerSlot || slot;
+
+ const normalizedKind = currentStreamKind;
+ if (normalizedKind === 'telemetry' || currentMediaKind === 'data') {
+ await _renderTelemetryStreamPanel(streamId, playerSlot, res);
return;
}
const playbackUrl = String(res.playback_url);
const safeUrl = playbackUrl.replace(/"/g, '"');
- const isVideo = String(mediaKind || '').toLowerCase() === 'video';
+ const isVideo = currentMediaKind === 'video';
const tag = isVideo ? 'video' : 'audio';
const extraAttrs = isVideo
? 'playsinline class="w-100" style="max-width:100%;border-radius:10px;background:#000;"'
: 'class="w-100" style="max-width:100%;"';
const mediaElId = `stream-player-${streamId.replace(/[^a-z0-9]/gi, '')}-${Date.now()}`;
- slot.style.display = 'block';
- slot.innerHTML = `
+ playerSlot.innerHTML = `
+ ${isVideo ? 'Live player' : 'Live listener'}
<${tag} id="${mediaElId}" controls autoplay preload="metadata" ${extraAttrs}>${tag}>
If playback does not start,
open stream URL in VLC or Safari.
`;
@@ -8740,7 +9506,7 @@ Add Community No
const isWebm = String(res.live_format || '').toLowerCase() === 'webm' ||
playbackUrl.indexOf('/segments/') < 0 && playbackUrl.indexOf('webm') >= 0;
if (isWebm) {
- await _startMSELiveViewer(streamId, playbackUrl, slot, mediaKind);
+ await _startMSELiveViewer(streamId, playbackUrl, playerSlot, currentMediaKind);
return;
}
@@ -8769,7 +9535,7 @@ Add Community No
console.warn('HLS fatal error', data.type, data.details);
if (data.type === HlsLib.ErrorTypes.MEDIA_ERROR) {
hls.destroy();
- _startMSELiveViewer(streamId, playbackUrl, slot, mediaKind).catch(() => {});
+ _startMSELiveViewer(streamId, playbackUrl, playerSlot, currentMediaKind).catch(() => {});
}
}
});
@@ -8796,89 +9562,54 @@ Add Community No
}
async function quickCreateLiveStream(mediaKind) {
- const mode = String(mediaKind || 'audio').toLowerCase() === 'video' ? 'video' : 'audio';
- if (!currentChannelId) {
- showAlert('Select a channel before creating a stream post', 'warning');
- return;
- }
- const titleHint = mode === 'video' ? 'Live camera feed' : 'Live audio feed';
- const channelLabelEl = document.getElementById('current-channel-name');
- const channelLabel = channelLabelEl ? channelLabelEl.textContent.trim() : String(currentChannelId);
- const defaultTitle = `${titleHint} ${channelLabel}`.trim();
- const title = window.prompt(`Stream title (${mode})`, defaultTitle);
- if (title === null) return;
- const cleanTitle = String(title || '').trim();
- if (!cleanTitle) {
- showAlert('Stream title is required', 'warning');
- return;
- }
- const description = window.prompt('Optional stream description', '') || '';
-
- try {
- const payload = {
- channel_id: currentChannelId,
- title: cleanTitle,
- description: String(description).trim(),
- stream_kind: 'media',
- media_kind: mode,
- protocol: 'hls',
- relay_allowed: currentChannelPrivacy === 'open',
- auto_post: true,
- start_now: true,
- post_content: mode === 'video'
- ? `Live video stream started: ${cleanTitle}`
- : `Live audio stream started: ${cleanTitle}`,
- };
- const res = await apiCall('/ajax/streams', {
- method: 'POST',
- body: JSON.stringify(payload),
- });
- if (!res || !res.success || !res.stream) {
- throw new Error((res && (res.error || res.message)) || 'Failed to create stream');
- }
- showAlert(`Stream "${cleanTitle}" created — click Go Live on the card to start broadcasting`, 'success');
- requestChannelThreadRefresh({ forceScroll: true });
- // Auto-open broadcaster panel for the new stream
- const newId = String(res.stream?.id || res.stream?.stream_id || '');
- if (newId) {
- setTimeout(() => {
- document.querySelectorAll(`[onclick*="openBroadcasterPanel"]`).forEach(btn => {
- const m = btn.getAttribute('onclick')?.match(/openBroadcasterPanel\('([^']+)','([^']+)','([^']+)'\)/);
- if (m && m[1] === newId) { openBroadcasterPanel(m[1], m[2], m[3]); }
- });
- }, 700);
- }
- } catch (err) {
- const msg = err && err.message ? err.message : 'Failed to create stream';
- showAlert(msg, 'danger');
- }
+ const mode = String(mediaKind || 'audio').toLowerCase() === 'video' ? 'video_ops' : 'audio_ops';
+ return openStreamCreateModal(mode);
}
const _activeBroadcasters = {};
+const _previewBroadcasters = {};
+
+function _stopPreviewStream(streamId) {
+ const previewState = _previewBroadcasters[streamId];
+ if (!previewState) return;
+ try { previewState.stream.getTracks().forEach((t) => t.stop()); } catch (_) {}
+ const previewEl = document.getElementById(`bc-preview-${streamId}`);
+ if (previewEl && previewEl.srcObject === previewState.stream) {
+ previewEl.srcObject = null;
+ }
+ delete _previewBroadcasters[streamId];
+}
async function openBroadcasterPanel(streamId, mediaKind, slotId) {
const slot = document.getElementById(slotId);
if (!slot) return;
+ const workspaceOwnerSlot = slot.querySelector('[data-stream-owner-tools="1"]');
+ const target = workspaceOwnerSlot || slot;
// If already open, toggle it closed
- if (slot.dataset.bcOpen === '1') {
- slot.dataset.bcOpen = '0';
- slot.style.display = 'none';
- slot.innerHTML = '';
- _stopLiveBroadcast(streamId);
+ if (target.dataset.bcOpen === '1') {
+ target.dataset.bcOpen = '0';
+ _stopPreviewStream(streamId);
+ if (!workspaceOwnerSlot) {
+ slot.style.display = 'none';
+ slot.innerHTML = '';
+ } else {
+ target.innerHTML = '';
+ }
return;
}
// Enumerate devices
let videoDevices = [], audioDevices = [];
try {
- await navigator.mediaDevices.getUserMedia({ video: mediaKind === 'video', audio: true });
+ const permissionStream = await navigator.mediaDevices.getUserMedia({ video: mediaKind === 'video', audio: true });
+ try { permissionStream.getTracks().forEach((t) => t.stop()); } catch (_) {}
const devices = await navigator.mediaDevices.enumerateDevices();
videoDevices = devices.filter(d => d.kind === 'videoinput');
audioDevices = devices.filter(d => d.kind === 'audioinput');
} catch (e) {
+ target.innerHTML = `Camera/mic access denied: ${e.message}
`;
slot.style.display = 'block';
- slot.innerHTML = `
Camera/mic access denied: ${e.message}
`;
return;
}
@@ -8887,7 +9618,7 @@
Add Community No
const audioOpts = audioDevices.map((d, i) =>
`${d.label || 'Mic ' + (i+1)} `).join('');
- slot.innerHTML = `
+ target.innerHTML = `
${mediaKind === 'video' ? `
@@ -8900,14 +9631,17 @@
Add Community No
${mediaKind === 'video' ? `
` : ''}
-
+
Start Broadcasting
+
+ Stop stream
+
`;
slot.style.display = 'block';
- slot.dataset.bcOpen = '1';
+ target.dataset.bcOpen = '1';
// Live preview
if (mediaKind === 'video') {
@@ -8919,20 +9653,26 @@ Add Community No
});
const preview = document.getElementById(`bc-preview-${streamId}`);
if (preview) preview.srcObject = stream;
+ _previewBroadcasters[streamId] = { stream };
document.getElementById(`bc-cam-${streamId}`)?.addEventListener('change', async (e) => {
- stream.getTracks().forEach(t => t.stop());
+ _stopPreviewStream(streamId);
const ns = await navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: e.target.value } }, audio: false });
if (preview) preview.srcObject = ns;
+ _previewBroadcasters[streamId] = { stream: ns };
});
} catch (_) {}
}
}
-async function _toggleBroadcast(streamId, mediaKind) {
- if (_activeBroadcasters[streamId]) { _stopLiveBroadcast(streamId); } else { await _startLiveBroadcast(streamId, mediaKind); }
+async function _toggleBroadcast(streamId, mediaKind, slotId) {
+ if (_activeBroadcasters[streamId]) {
+ await _stopLiveBroadcast(streamId, slotId);
+ } else {
+ await _startLiveBroadcast(streamId, mediaKind, slotId);
+ }
}
-async function _startLiveBroadcast(streamId, mode) {
+async function _startLiveBroadcast(streamId, mode, slotId = null) {
const btn = document.getElementById(`bc-btn-${streamId}`);
const statusEl = document.getElementById(`bc-status-${streamId}`);
if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; }
@@ -8972,6 +9712,21 @@ Add Community No
const rec = new MediaRecorder(mediaStream, { mimeType, videoBitsPerSecond: 1_500_000 });
let segIdx = 0, initSent = false, segNames = [], seqStart = 0;
+ _stopPreviewStream(streamId);
+ const previewEl = document.getElementById(`bc-preview-${streamId}`);
+ if (previewEl && mode === 'video') {
+ previewEl.srcObject = mediaStream;
+ }
+
+ try {
+ await _setStreamLifecycle(streamId, 'start', slotId);
+ } catch (e) {
+ try { mediaStream.getTracks().forEach(t => t.stop()); } catch (_) {}
+ if (statusEl) statusEl.textContent = 'Start failed: ' + e.message;
+ if (btn) { btn.disabled = false; btn.innerHTML = ' Start Broadcasting'; }
+ return;
+ }
+
_activeBroadcasters[streamId] = { rec, mediaStream, tok };
rec.ondataavailable = async (e) => {
@@ -8995,14 +9750,40 @@ Add Community No
if (statusEl) statusEl.textContent = '● Live';
}
-function _stopLiveBroadcast(streamId) {
+async function _stopLiveBroadcast(streamId, slotId = null) {
const bc = _activeBroadcasters[streamId]; if (!bc) return;
try { bc.rec.stop(); } catch (_) {}
try { bc.mediaStream.getTracks().forEach(t => t.stop()); } catch (_) {}
delete _activeBroadcasters[streamId];
+ _stopPreviewStream(streamId);
const btn = document.getElementById(`bc-btn-${streamId}`);
if (btn) { btn.innerHTML = ' Start Broadcasting'; btn.classList.replace('btn-danger','btn-success'); }
- const s = document.getElementById(`bc-status-${streamId}`); if (s) s.textContent = 'Stopped';
+ const s = document.getElementById(`bc-status-${streamId}`); if (s) s.textContent = 'Stopping…';
+ const previewEl = document.getElementById(`bc-preview-${streamId}`);
+ if (previewEl) previewEl.srcObject = null;
+ try {
+ await _setStreamLifecycle(streamId, 'stop', slotId);
+ if (s) s.textContent = 'Stopped';
+ } catch (e) {
+ if (s) s.textContent = 'Stop failed: ' + e.message;
+ showAlert(`Stop stream failed: ${e.message}`, 'danger');
+ }
+}
+
+async function stopStreamOwner(streamId, slotId) {
+ if (_activeBroadcasters[streamId]) {
+ await _stopLiveBroadcast(streamId, slotId);
+ return;
+ }
+ _stopPreviewStream(streamId);
+ const previewEl = document.getElementById(`bc-preview-${streamId}`);
+ if (previewEl) previewEl.srcObject = null;
+ try {
+ await _setStreamLifecycle(streamId, 'stop', slotId);
+ showAlert('Stream stopped', 'success');
+ } catch (e) {
+ showAlert(`Stop stream failed: ${e.message}`, 'danger');
+ }
}
async function _pushBcSeg(sid, tok, name, buf) {
diff --git a/canopy/ui/templates/feed.html b/canopy/ui/templates/feed.html
index b70946e..f3c65dc 100644
--- a/canopy/ui/templates/feed.html
+++ b/canopy/ui/templates/feed.html
@@ -3535,18 +3535,32 @@ Your feed is empty
width: 100%;
}
-.embed-preview.youtube-embed iframe {
+.embed-preview.iframe-embed iframe,
+.embed-preview.native-video-embed video,
+.embed-preview.native-audio-embed audio {
max-width: 100%;
width: 100%;
+}
+
+.embed-preview.iframe-embed iframe {
height: auto;
aspect-ratio: 16 / 9;
}
+.embed-preview.map-service-embed iframe {
+ min-height: 320px;
+}
+
+.embed-preview.chart-service-embed iframe {
+ min-height: 360px;
+}
+
.embed-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
max-width: 100%;
+ align-items: start;
}
/* Character counter */
@@ -3569,6 +3583,10 @@ Your feed is empty
.post-card .card-body p { overflow-wrap: break-word; word-break: break-word; }
.comments-section .input-group .form-control { min-height: 44px; font-size: 16px; }
.embed-grid { grid-template-columns: 1fr !important; }
+ .provider-embed-card { padding: 12px 14px !important; }
+ .provider-embed-head { gap: 10px !important; }
+ .embed-preview.map-service-embed iframe,
+ .embed-preview.chart-service-embed iframe { min-height: 280px; }
audio, video { max-width: 100%; }
}
@media (max-width: 576px) {
@@ -6424,7 +6442,7 @@ Comments
});
// --- Rich link embed rendering for feed posts ---
- // Process server-rendered post content to embed YouTube/X previews
+ // Process server-rendered post content to embed shared Canopy provider previews.
document.addEventListener('DOMContentLoaded', function() {
if (typeof processRichEmbeds === 'function') {
processRichEmbeds('.post-content p');
diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md
index f13d8ea..97f4c86 100644
--- a/docs/API_REFERENCE.md
+++ b/docs/API_REFERENCE.md
@@ -151,6 +151,7 @@ Rich media notes:
- Channel messages accept top-level `attachments` arrays. Feed posts currently carry attachments under `metadata.attachments`.
- Uploaded images can now be referenced inline inside message or feed body content with Markdown image syntax using a Canopy file URI: ``.
- Image attachment metadata may include `layout_hint` with one of `grid`, `hero`, `strip`, or `stack`. Invalid values are stripped during normalization.
+- URLs from supported providers (YouTube, Vimeo, Loom, Spotify, SoundCloud, OpenStreetMap, TradingView, and direct audio/video links) are automatically rendered as rich embeds in the UI. Google Maps links render as inline map iframes when `CANOPY_GOOGLE_MAPS_EMBED_API_KEY` is configured; otherwise they fall back to safe preview cards.
---
@@ -177,6 +178,9 @@ Security notes:
- Ingest/view endpoints return generic not-found responses for invalid or unauthorized tokens.
- Stream card attachments are regular channel attachments (`kind=stream`) to preserve backward-compatible mesh propagation.
- `stream_kind=media` uses HLS (`protocol=hls`), while `stream_kind=telemetry` uses event transport (`protocol=events-json`).
+- Stream lifecycle changes (`start`/`stop`) update stored stream-card attachment metadata in all affected channel messages and emit edit events so remote peers receive the new status without polling.
+- Playback and ingest endpoints use a dedicated high-ceiling rate limiter separate from the general API throttle, preventing active stream sessions from hitting `429` responses under normal player polling.
+- Stream tokens support a `/tokens` refresh path for longer live sessions.
---
diff --git a/docs/GITHUB_RELEASE_v0.4.89.md b/docs/GITHUB_RELEASE_v0.4.89.md
new file mode 100644
index 0000000..9381739
--- /dev/null
+++ b/docs/GITHUB_RELEASE_v0.4.89.md
@@ -0,0 +1,33 @@
+# Canopy v0.4.89
+
+Canopy `0.4.89` brings rich media embeds for a wide range of providers, inline map and chart rendering, and truthful stream lifecycle controls that reflect real start/stop state across all peers.
+
+## Highlights
+
+- **Rich embed provider expansion**: YouTube, Vimeo, Loom, Spotify, SoundCloud, direct audio/video URLs, OpenStreetMap, TradingView, and Google Maps links are now rendered as rich embeds or safe preview cards across channels and the feed. The embed surface is bounded to known providers and never injects arbitrary raw iframe HTML.
+- **Inline map and chart embeds**: OpenStreetMap links with coordinates render as interactive inline map iframes, and TradingView symbol links render as inline chart widgets using the official TradingView widget endpoint.
+- **Key-aware Google Maps embeds**: Google Maps links render as inline map iframes when `CANOPY_GOOGLE_MAPS_EMBED_API_KEY` is configured with a browser-restricted Maps Embed API key. Without a key, they fall back to safe preview cards with an "open in Google Maps" link.
+- **Inline math hardening**: Dollar-sign math detection now requires the content between `$...$` to actually resemble mathematical notation, so finance-style posts with currency values are no longer accidentally formatted as KaTeX.
+- **Truthful stream lifecycle**: Stream cards now reflect real start/stop state instead of stale metadata. Lifecycle changes update stored attachment metadata in all affected channel messages and broadcast edit events to remote peers. Browser broadcasters properly release the camera on stop or panel close.
+- **Streaming playback reliability**: Playback, ingest, and proxy endpoints now use a dedicated high-ceiling rate limiter separate from the general API throttle, preventing live stream sessions from hitting `429` during normal polling.
+
+## What changed since 0.4.83
+
+This release rolls up `0.4.84` through `0.4.89`. See [CHANGELOG.md](../CHANGELOG.md) for per-version details covering:
+
+- streaming runtime readiness and token refresh surfaces (`0.4.84`)
+- streaming playback rate-limit carve-out (`0.4.85`)
+- truthful stream lifecycle controls and cross-peer card truth (`0.4.86`-`0.4.87`)
+- rich embed provider expansion and math hardening (`0.4.88`)
+- inline map/chart embeds and Google Maps query-link fix (`0.4.89`)
+
+## Getting Started
+
+1. Install and run: [docs/QUICKSTART.md](https://github.com/kwalus/Canopy/blob/main/docs/QUICKSTART.md)
+2. Configure agents: [docs/AGENT_ONBOARDING.md](https://github.com/kwalus/Canopy/blob/main/docs/AGENT_ONBOARDING.md)
+3. Connect MCP clients: [docs/MCP_QUICKSTART.md](https://github.com/kwalus/Canopy/blob/main/docs/MCP_QUICKSTART.md)
+4. Explore endpoints: [docs/API_REFERENCE.md](https://github.com/kwalus/Canopy/blob/main/docs/API_REFERENCE.md)
+
+## Notes
+
+Canopy remains early-stage software. Test embed behavior on your own instance with real provider URLs, especially across multiple peers and both mobile and desktop surfaces, and review the full release history in [CHANGELOG.md](../CHANGELOG.md).
diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md
index bfb4b30..d411149 100644
--- a/docs/QUICKSTART.md
+++ b/docs/QUICKSTART.md
@@ -1,7 +1,7 @@
# Canopy Quick Start
This guide is the primary technical first-run path for Canopy. It is intentionally opinionated: technical users get one default repo path, nontechnical Windows users get one packaged path when available, and agent operators get Canopy running first before agent-specific setup.
-Version scope: this quick start is aligned to Canopy `0.4.83`.
+Version scope: this quick start is aligned to Canopy `0.4.89`.
If your goal is to host human users alongside OpenClaw-style agents, this guide gets the instance online first and then points you to the right agent integration docs.
@@ -139,6 +139,17 @@ python -m canopy
Canopy will create `CANOPY_DATA_ROOT/devices//` and use it for the database, peer identity, and file storage. You can set this in your shell profile or in an install script so every run uses the same location. Packaged tray builds already use a per-user app data directory; this env var is for development or script-based installs where you want to avoid storing user data inside the project tree.
+### Optional: Google Maps inline embeds
+
+Canopy renders shared Google Maps links as safe preview cards by default. To promote them to inline map iframes using the official Maps Embed API, set a browser-restricted API key before starting:
+
+```bash
+export CANOPY_GOOGLE_MAPS_EMBED_API_KEY="your-browser-restricted-key"
+python -m canopy
+```
+
+The key should be restricted to the Maps Embed API with a referrer restriction matching the domains that will serve the Canopy UI. Without this key, Google Maps links continue to render as safe cards with an "open in Google Maps" link.
+
---
## 5) First 10-minute checklist
diff --git a/pyproject.toml b/pyproject.toml
index a8a12cb..51d704e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "canopy"
-version = "0.4.83"
+version = "0.4.89"
description = "Local-first peer-to-peer collaboration for humans and AI agents."
readme = "README.md"
requires-python = ">=3.10"
diff --git a/tests/test_api_stream_endpoints.py b/tests/test_api_stream_endpoints.py
index 7fec0d5..fbf2f90 100644
--- a/tests/test_api_stream_endpoints.py
+++ b/tests/test_api_stream_endpoints.py
@@ -32,6 +32,7 @@ def __init__(self, *args, **kwargs):
sys.modules['zeroconf'] = zeroconf_stub
from canopy.api.routes import create_api_blueprint
+from canopy.core.app import _api_limiter, _install_rate_limiting, _stream_playback_limiter
from canopy.core.streams import StreamManager
from canopy.security.api_keys import ApiKeyInfo, Permission
@@ -222,6 +223,7 @@ def test_create_stream_requires_membership(self) -> None:
self.assertTrue(sent_attachments)
self.assertEqual(sent_attachments[0].get('kind'), 'stream')
self.assertEqual(sent_attachments[0].get('stream_id'), stream.get('id'))
+ self.assertEqual(sent_attachments[0].get('status'), 'live')
denied = self.client.post(
'/api/v1/streams',
@@ -233,6 +235,43 @@ def test_create_stream_requires_membership(self) -> None:
)
self.assertEqual(denied.status_code, 404)
+ def test_owner_can_stop_stream_via_api_endpoint(self) -> None:
+ created = self.client.post(
+ '/api/v1/streams',
+ json={
+ 'channel_id': 'C1',
+ 'title': 'Ops stop test',
+ 'media_kind': 'audio',
+ 'auto_post': False,
+ 'start_now': True,
+ },
+ headers=self._headers('key-member'),
+ )
+ self.assertEqual(created.status_code, 201)
+ stream_id = (created.get_json() or {}).get('stream', {}).get('id')
+ self.assertTrue(stream_id)
+ stopped = self.client.post(
+ f'/api/v1/streams/{stream_id}/stop',
+ headers=self._headers('key-member'),
+ )
+ self.assertEqual(stopped.status_code, 200)
+ payload = stopped.get_json() or {}
+ self.assertTrue(payload.get('success'))
+ self.assertEqual((payload.get('stream') or {}).get('status'), 'stopped')
+
+ def test_stream_health_reports_runtime_readiness(self) -> None:
+ response = self.client.get(
+ '/api/v1/streams/health',
+ headers=self._headers('key-member'),
+ )
+ self.assertEqual(response.status_code, 200)
+ payload = response.get_json() or {}
+ self.assertTrue(payload.get('success'))
+ health = payload.get('health') or {}
+ self.assertTrue(health.get('stream_manager_ready'))
+ self.assertIn('storage_root', health)
+ self.assertEqual(health.get('latency_mode_supported'), 'hls')
+
def test_tokenized_ingest_and_playback_flow(self) -> None:
create_resp = self.client.post(
'/api/v1/streams',
@@ -308,6 +347,140 @@ def test_tokenized_ingest_and_playback_flow(self) -> None:
)
self.assertEqual(bad_segment_token_resp.status_code, 404)
+ def test_refresh_view_token_revokes_old_token(self) -> None:
+ create_resp = self.client.post(
+ '/api/v1/streams',
+ json={
+ 'channel_id': 'C1',
+ 'title': 'Refreshable stream',
+ 'media_kind': 'audio',
+ 'auto_post': False,
+ },
+ headers=self._headers('key-member'),
+ )
+ self.assertEqual(create_resp.status_code, 201)
+ stream_id = (create_resp.get_json() or {}).get('stream', {}).get('id')
+ self.assertTrue(stream_id)
+
+ join_resp = self.client.post(
+ f'/api/v1/streams/{stream_id}/join',
+ json={'ttl_seconds': 600},
+ headers=self._headers('key-member'),
+ )
+ self.assertEqual(join_resp.status_code, 200)
+ old_token = (join_resp.get_json() or {}).get('token')
+ self.assertTrue(old_token)
+
+ refresh_resp = self.client.post(
+ f'/api/v1/streams/{stream_id}/tokens/refresh',
+ json={'scope': 'view', 'token': old_token, 'ttl_seconds': 900},
+ headers=self._headers('key-member'),
+ )
+ self.assertEqual(refresh_resp.status_code, 200)
+ refreshed = refresh_resp.get_json() or {}
+ self.assertTrue(refreshed.get('success'))
+ self.assertNotEqual(refreshed.get('token'), old_token)
+ self.assertIn('/manifest.m3u8?token=', refreshed.get('playback_url') or '')
+
+ old_manifest = self.client.get(
+ f'/api/v1/streams/{stream_id}/manifest.m3u8?token={old_token}',
+ )
+ self.assertEqual(old_manifest.status_code, 404)
+
+ def test_empty_manifest_ingest_returns_actionable_hint(self) -> None:
+ create_resp = self.client.post(
+ '/api/v1/streams',
+ json={
+ 'channel_id': 'C1',
+ 'title': 'Hint stream',
+ 'media_kind': 'video',
+ 'auto_post': False,
+ },
+ headers=self._headers('key-member'),
+ )
+ self.assertEqual(create_resp.status_code, 201)
+ stream_id = (create_resp.get_json() or {}).get('stream', {}).get('id')
+ self.assertTrue(stream_id)
+
+ ingest_token_resp = self.client.post(
+ f'/api/v1/streams/{stream_id}/tokens',
+ json={'scope': 'ingest', 'ttl_seconds': 600},
+ headers=self._headers('key-member'),
+ )
+ self.assertEqual(ingest_token_resp.status_code, 200)
+ ingest_token = (ingest_token_resp.get_json() or {}).get('token')
+ self.assertTrue(ingest_token)
+
+ put_manifest = self.client.put(
+ f'/api/v1/streams/{stream_id}/ingest/manifest?token={ingest_token}',
+ data=b'',
+ headers={'Content-Type': 'application/vnd.apple.mpegurl'},
+ )
+ self.assertEqual(put_manifest.status_code, 400)
+ payload = put_manifest.get_json() or {}
+ self.assertEqual(payload.get('error'), 'empty_ingest_payload')
+ self.assertEqual(payload.get('hint'), 'possible_empty_upload_or_proxy_buffering_issue')
+
+ def test_manifest_playback_uses_stream_read_rate_limit_not_generic_api_limit(self) -> None:
+ _api_limiter._buckets.clear()
+ _stream_playback_limiter._buckets.clear()
+ _install_rate_limiting(self.client.application)
+
+ create_resp = self.client.post(
+ '/api/v1/streams',
+ json={
+ 'channel_id': 'C1',
+ 'title': 'Playback limiter stream',
+ 'media_kind': 'video',
+ 'auto_post': False,
+ },
+ headers=self._headers('key-member'),
+ )
+ self.assertEqual(create_resp.status_code, 201)
+ stream_id = (create_resp.get_json() or {}).get('stream', {}).get('id')
+ self.assertTrue(stream_id)
+
+ ingest_token_resp = self.client.post(
+ f'/api/v1/streams/{stream_id}/tokens',
+ json={'scope': 'ingest', 'ttl_seconds': 600},
+ headers=self._headers('key-member'),
+ )
+ self.assertEqual(ingest_token_resp.status_code, 200)
+ ingest_token = (ingest_token_resp.get_json() or {}).get('token')
+ self.assertTrue(ingest_token)
+
+ put_manifest = self.client.put(
+ f'/api/v1/streams/{stream_id}/ingest/manifest?token={ingest_token}',
+ data=b'#EXTM3U\n#EXT-X-VERSION:3\n#EXTINF:2.0,\nseg000001.ts\n',
+ headers={'Content-Type': 'application/vnd.apple.mpegurl'},
+ )
+ self.assertEqual(put_manifest.status_code, 200)
+
+ put_segment = self.client.put(
+ f'/api/v1/streams/{stream_id}/ingest/segments/seg000001.ts?token={ingest_token}',
+ data=b'\x01\x02\x03',
+ headers={'Content-Type': 'video/mp2t'},
+ )
+ self.assertEqual(put_segment.status_code, 200)
+
+ join_resp = self.client.post(
+ f'/api/v1/streams/{stream_id}/join',
+ json={'ttl_seconds': 600},
+ headers=self._headers('key-member'),
+ )
+ self.assertEqual(join_resp.status_code, 200)
+ playback_token = (join_resp.get_json() or {}).get('token')
+ self.assertTrue(playback_token)
+
+ statuses = []
+ for _ in range(25):
+ manifest_resp = self.client.get(
+ f'/api/v1/streams/{stream_id}/manifest.m3u8?token={playback_token}',
+ )
+ statuses.append(manifest_resp.status_code)
+ self.assertNotIn(429, statuses)
+ self.assertTrue(all(code == 200 for code in statuses))
+
def test_telemetry_stream_event_ingest_and_read(self) -> None:
create_resp = self.client.post(
'/api/v1/streams',
diff --git a/tests/test_channel_message_route_regressions.py b/tests/test_channel_message_route_regressions.py
index 6be18c4..26ec8a6 100644
--- a/tests/test_channel_message_route_regressions.py
+++ b/tests/test_channel_message_route_regressions.py
@@ -7,6 +7,7 @@
import tempfile
import types
import unittest
+from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -219,6 +220,63 @@ def _race_get_channel_messages(*args, **kwargs):
payload = response.get_json() or {}
self.assertEqual(payload.get('workspace_event_cursor'), 5)
+ def test_channel_messages_snapshot_refreshes_remote_stream_attachment_status(self) -> None:
+ message = MagicMock()
+ message.id = 'M-stream'
+ message.channel_id = 'general'
+ message.user_id = 'owner'
+ message.content = 'Remote stream card'
+ message.created_at = datetime.now(timezone.utc)
+ message.expires_at = None
+ message.to_dict.return_value = {
+ 'id': 'M-stream',
+ 'channel_id': 'general',
+ 'user_id': 'owner',
+ 'content': 'Remote stream card',
+ 'type': 'file',
+ 'created_at': message.created_at.isoformat(),
+ 'attachments': [
+ {
+ 'kind': 'stream',
+ 'type': 'application/vnd.canopy.stream+json',
+ 'stream_id': 'ST-remote',
+ 'status': 'created',
+ 'title': 'Remote watch',
+ }
+ ],
+ 'security': {},
+ 'reactions': {},
+ }
+ self.channel_manager.get_channel_messages.return_value = [message]
+ self.client.application.config['STREAM_MANAGER'] = MagicMock()
+ self.client.application.config['STREAM_MANAGER'].get_stream_for_user.return_value = None
+ route_components = (
+ self.db_manager,
+ MagicMock(),
+ MagicMock(),
+ MagicMock(),
+ self.channel_manager,
+ self.file_manager,
+ MagicMock(),
+ None,
+ None,
+ MagicMock(),
+ self.p2p_manager,
+ )
+
+ with patch('canopy.ui.routes._get_app_components_any', return_value=route_components), \
+ patch('canopy.ui.routes._resolve_p2p_stream', return_value={'remote_base': 'http://peer.test'}), \
+ patch('canopy.ui.routes._probe_remote_stream_manifest_live', return_value=True):
+ response = self.client.get('/ajax/channel_messages/general')
+
+ self.assertEqual(response.status_code, 200)
+ payload = response.get_json() or {}
+ messages = payload.get('messages') or []
+ self.assertEqual(len(messages), 1)
+ attachments = messages[0].get('attachments') or []
+ self.assertEqual(len(attachments), 1)
+ self.assertEqual(attachments[0].get('status'), 'live')
+
def test_create_community_note_on_channel_message_emits_metadata_event(self) -> None:
response = self.client.post(
'/ajax/community_notes',
diff --git a/tests/test_embed_frontend_regressions.py b/tests/test_embed_frontend_regressions.py
new file mode 100644
index 0000000..8baf278
--- /dev/null
+++ b/tests/test_embed_frontend_regressions.py
@@ -0,0 +1,78 @@
+"""Regression guards for the shared rich embed provider surface."""
+
+from pathlib import Path
+import unittest
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class TestEmbedFrontendRegressions(unittest.TestCase):
+ def test_main_js_has_shared_provider_registry_for_new_embeds(self) -> None:
+ main_js = (ROOT / "canopy" / "ui" / "static" / "js" / "canopy-main.js").read_text(encoding="utf-8")
+ self.assertIn("const RICH_EMBED_PROVIDERS = [", main_js)
+ self.assertIn("key: 'vimeo'", main_js)
+ self.assertIn("key: 'loom'", main_js)
+ self.assertIn("key: 'spotify'", main_js)
+ self.assertIn("key: 'soundcloud'", main_js)
+ self.assertIn("key: 'google_maps'", main_js)
+ self.assertIn("key: 'openstreetmap'", main_js)
+ self.assertIn("key: 'tradingview'", main_js)
+ self.assertIn("key: 'direct_video'", main_js)
+ self.assertIn("key: 'direct_audio'", main_js)
+ self.assertIn("function collectProviderEmbeds(html)", main_js)
+ self.assertIn("function isEmbedMatchInsideHtmlTag(html, matchIndex)", main_js)
+ self.assertIn("if (isEmbedMatchInsideHtmlTag(html, matchIndex)) {", main_js)
+ self.assertIn("function buildGoogleMapsEmbedUrl(rawUrl)", main_js)
+ self.assertIn("function buildOpenStreetMapEmbedUrl(rawUrl)", main_js)
+ self.assertIn("function buildTradingViewEmbedUrl(rawUrl)", main_js)
+ self.assertIn("function parseTradingViewSymbol(rawUrl)", main_js)
+ self.assertIn("buildIframeEmbedPreview(", main_js)
+ self.assertIn("buildNativeMediaEmbed(", main_js)
+ self.assertIn("buildProviderCardEmbed(", main_js)
+ self.assertIn("const referrerPolicy = escapeEmbedAttr(options.referrerPolicy || 'strict-origin-when-cross-origin');", main_js)
+ self.assertIn("google\\.[^\\/]+\\/maps(?:[/?#][^\\s<\"]*)?", main_js)
+ self.assertIn("maps\\.app\\.goo\\.gl\\/?[^\\s<\"]*", main_js)
+ self.assertIn("s.tradingview.com/widgetembed/?", main_js)
+ self.assertIn("www.google.com/maps/embed/v1/search?key=", main_js)
+ self.assertIn("referrerPolicy: 'no-referrer-when-downgrade'", main_js)
+
+ def test_base_template_styles_support_iframe_cards_and_native_media(self) -> None:
+ base_template = (ROOT / "canopy" / "ui" / "templates" / "base.html").read_text(encoding="utf-8")
+ self.assertIn("googleMapsEmbedApiKey:", base_template)
+ self.assertIn(".embed-preview::before", base_template)
+ self.assertIn(".iframe-embed iframe,", base_template)
+ self.assertIn(".native-media-embed video,", base_template)
+ self.assertIn(".map-service-embed iframe", base_template)
+ self.assertIn(".chart-service-embed iframe", base_template)
+ self.assertIn(".provider-card-embed", base_template)
+ self.assertIn(".provider-embed-card", base_template)
+ self.assertIn(".provider-embed-card:focus-visible", base_template)
+ self.assertIn(".embed-provider-pill", base_template)
+ self.assertIn(".embed-provider-caption", base_template)
+ self.assertIn(".spotify-embed { --embed-accent: #1db954; }", base_template)
+ self.assertIn(".tradingview-card-embed,", base_template)
+ self.assertIn(".tradingview-embed { --embed-accent: #4f8cff; }", base_template)
+
+ def test_feed_template_uses_shared_provider_preview_language(self) -> None:
+ feed_template = (ROOT / "canopy" / "ui" / "templates" / "feed.html").read_text(encoding="utf-8")
+ self.assertIn(".embed-preview.iframe-embed iframe,", feed_template)
+ self.assertIn(".embed-preview.map-service-embed iframe {", feed_template)
+ self.assertIn(".embed-preview.chart-service-embed iframe {", feed_template)
+ self.assertIn("embed shared Canopy provider previews", feed_template)
+ self.assertIn(".provider-embed-card { padding: 12px 14px !important; }", feed_template)
+
+ def test_math_rendering_only_enables_inline_dollars_for_likely_math(self) -> None:
+ main_js = (ROOT / "canopy" / "ui" / "static" / "js" / "canopy-main.js").read_text(encoding="utf-8")
+ self.assertIn("function hasExplicitMathDelimiters(text)", main_js)
+ self.assertIn("function isLikelyMathInlineContent(content)", main_js)
+ self.assertIn("function hasLikelyInlineMath(text)", main_js)
+ self.assertIn("function buildMathDelimitersForText(text)", main_js)
+ self.assertIn("const delimiters = buildMathDelimitersForText(sourceText);", main_js)
+ self.assertIn("if (!delimiters.length) return false;", main_js)
+ self.assertIn("if (hasLikelyInlineMath(value)) {", main_js)
+ self.assertNotIn("{ left: '$', right: '$', display: false }\n ],", main_js)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_frontend_regressions.py b/tests/test_frontend_regressions.py
index 383fbed..f8df7eb 100644
--- a/tests/test_frontend_regressions.py
+++ b/tests/test_frontend_regressions.py
@@ -76,6 +76,24 @@ def test_active_channel_refreshes_when_sidebar_receives_new_message_event(self)
self.assertIn("if (currentChannelId && channelId === currentChannelId) {", channels_template)
self.assertIn("requestChannelThreadRefresh();", channels_template)
+ def test_stream_owner_controls_drive_real_lifecycle_endpoints(self) -> None:
+ channels_template = (ROOT / 'canopy' / 'ui' / 'templates' / 'channels.html').read_text(encoding='utf-8')
+ self.assertIn("function _setStreamLifecycle(streamId, action, slotId)", channels_template)
+ self.assertIn("`/ajax/streams/${encodeURIComponent(streamId)}/${action}`", channels_template)
+ self.assertIn("function stopStreamOwner(streamId, slotId)", channels_template)
+ self.assertIn("data-stream-status-chip=\"1\"", channels_template)
+ self.assertIn("data-stream-status-value=\"1\"", channels_template)
+ self.assertIn("const _previewBroadcasters = {};", channels_template)
+ self.assertIn("function _stopPreviewStream(streamId)", channels_template)
+ self.assertIn("_stopPreviewStream(streamId);", channels_template)
+ self.assertIn("const permissionStream = await navigator.mediaDevices.getUserMedia", channels_template)
+ self.assertIn("permissionStream.getTracks().forEach((t) => t.stop())", channels_template)
+
+ def test_channels_route_does_not_shadow_template_config(self) -> None:
+ ui_routes = (ROOT / 'canopy' / 'ui' / 'routes.py').read_text(encoding='utf-8')
+ self.assertIn("return render_template('channels.html',", ui_routes)
+ self.assertNotIn("config=config,", ui_routes)
+
def test_structured_validation_ignores_plain_unknown_section_headers(self) -> None:
main_js = (ROOT / 'canopy' / 'ui' / 'static' / 'js' / 'canopy-main.js').read_text(encoding='utf-8')
self.assertIn("const suggestedTag = TAG_SUGGESTIONS[tag] || null;", main_js)
diff --git a/tests/test_profile_sync_metadata.py b/tests/test_profile_sync_metadata.py
index 0a655d6..7bad6a2 100644
--- a/tests/test_profile_sync_metadata.py
+++ b/tests/test_profile_sync_metadata.py
@@ -225,6 +225,10 @@ def test_profile_sync_includes_local_key_only_users(self) -> None:
app = create_app()
db_manager = app.config['DB_MANAGER']
p2p_manager = app.config['P2P_MANAGER']
+ stream_manager = app.config.get('STREAM_MANAGER')
+
+ self.assertIsNotNone(stream_manager)
+ self.assertEqual(stream_manager.__class__.__name__, 'StreamManager')
with app.app_context():
with db_manager.get_connection() as conn:
diff --git a/tests/test_stream_manager.py b/tests/test_stream_manager.py
index 36f6be7..683661f 100644
--- a/tests/test_stream_manager.py
+++ b/tests/test_stream_manager.py
@@ -124,6 +124,21 @@ def test_issue_token_rejects_invalid_ttl(self) -> None:
self.assertIsNone(token_payload)
self.assertEqual(token_error, 'invalid_ttl')
+ def test_stream_health_and_default_latency_metadata(self) -> None:
+ stream_row, error = self.manager.create_stream(
+ channel_id='Cmain',
+ created_by='u-member',
+ title='Health test',
+ )
+ self.assertIsNone(error)
+ self.assertEqual((stream_row or {}).get('metadata', {}).get('latency_mode'), 'hls')
+
+ health = self.manager.get_runtime_health()
+ self.assertTrue(health.get('stream_manager_ready'))
+ self.assertEqual(health.get('latency_mode_supported'), 'hls')
+ self.assertIn('storage_root', health)
+ self.assertGreaterEqual(int(health.get('streams_total') or 0), 1)
+
def test_view_and_ingest_scope_authorization(self) -> None:
stream_row, error = self.manager.create_stream(
channel_id='Cmain',
@@ -173,6 +188,62 @@ def test_view_and_ingest_scope_authorization(self) -> None:
self.assertIsNone(denied_ingest)
self.assertEqual(denied_ingest_err, 'not_authorized')
+ def test_refresh_token_revokes_old_token(self) -> None:
+ stream_row, error = self.manager.create_stream(
+ channel_id='Cmain',
+ created_by='u-member',
+ title='Refresh test',
+ )
+ self.assertIsNone(error)
+ stream_id = stream_row['id']
+
+ view_payload, view_error = self.manager.issue_token(
+ stream_id=stream_id,
+ user_id='u-member',
+ scope='view',
+ ttl_seconds=300,
+ )
+ self.assertIsNone(view_error)
+ self.assertIsNotNone(view_payload)
+
+ refreshed, refresh_error = self.manager.refresh_token(
+ stream_id=stream_id,
+ current_token=view_payload['token'],
+ scope='view',
+ user_id='u-member',
+ ttl_seconds=600,
+ metadata={'issued_via': 'test'},
+ )
+ self.assertIsNone(refresh_error)
+ self.assertIsNotNone(refreshed)
+ self.assertNotEqual(refreshed['token'], view_payload['token'])
+ _, old_error = self.manager.validate_token(
+ stream_id=stream_id,
+ token=view_payload['token'],
+ scope='view',
+ )
+ self.assertEqual(old_error, 'revoked_token')
+
+ def test_status_changes_sync_stream_attachment_status_hook(self) -> None:
+ stream_row, error = self.manager.create_stream(
+ channel_id='Cmain',
+ created_by='u-member',
+ title='Lifecycle sync test',
+ )
+ self.assertIsNone(error)
+ stream_id = (stream_row or {}).get('id')
+ self.assertTrue(stream_id)
+
+ started, start_error = self.manager.start_stream(stream_id, 'u-member')
+ self.assertIsNone(start_error)
+ self.assertEqual((started or {}).get('status'), 'live')
+ self.manager.channel_manager.update_stream_attachment_status.assert_called_with(stream_id, 'live')
+
+ stopped, stop_error = self.manager.stop_stream(stream_id, 'u-member')
+ self.assertIsNone(stop_error)
+ self.assertEqual((stopped or {}).get('status'), 'stopped')
+ self.manager.channel_manager.update_stream_attachment_status.assert_called_with(stream_id, 'stopped')
+
def test_manifest_rewrite_and_segment_guards(self) -> None:
stream_row, error = self.manager.create_stream(
channel_id='Cmain',
diff --git a/tests/test_ui_stream_setup_endpoint.py b/tests/test_ui_stream_setup_endpoint.py
index 8223b95..ebf73b5 100644
--- a/tests/test_ui_stream_setup_endpoint.py
+++ b/tests/test_ui_stream_setup_endpoint.py
@@ -107,6 +107,10 @@ def setUp(self) -> None:
self.conn.commit()
self.db_manager = _FakeDbManager(self.conn)
+ self.channel_manager = MagicMock()
+ self.channel_manager.send_message.return_value = types.SimpleNamespace(id='Mstream-ui-1', created_at=None)
+ self.p2p_manager = MagicMock()
+ self.p2p_manager.get_peer_id.return_value = 'peer-local'
self.stream_manager = StreamManager(
db=self.db_manager,
channel_manager=MagicMock(),
@@ -128,13 +132,13 @@ def setUp(self) -> None:
MagicMock(), # api_key_manager
MagicMock(), # trust_manager
MagicMock(), # message_manager
- MagicMock(), # channel_manager
+ self.channel_manager, # channel_manager
MagicMock(), # file_manager
MagicMock(), # feed_manager
MagicMock(), # interaction_manager
MagicMock(), # profile_manager
MagicMock(), # config
- MagicMock(), # p2p_manager
+ self.p2p_manager, # p2p_manager
)
self.get_components_patcher = patch(
'canopy.ui.routes.get_app_components',
@@ -175,11 +179,101 @@ def test_owner_gets_setup_bundle_for_media_stream(self) -> None:
ingest = setup.get('ingest') or {}
playback = setup.get('playback') or {}
commands = setup.get('commands') or {}
+ preflight = setup.get('preflight') or {}
+ refresh = setup.get('token_refresh') or {}
+ ttl_seconds = setup.get('ttl_seconds') or {}
self.assertIn('/ingest/manifest?token=', ingest.get('manifest_url') or '')
self.assertIn('/ingest/segments/seg%06d.ts?token=', ingest.get('segment_url_template') or '')
self.assertIn('/manifest.m3u8?token=', playback.get('url') or '')
self.assertIn('ffmpeg', commands.get('posix') or '')
self.assertIn('ffmpeg', commands.get('powershell') or '')
+ self.assertTrue(preflight.get('stream_manager_ready'))
+ self.assertIn('ffmpeg_found', preflight)
+ self.assertEqual(preflight.get('latency_mode_supported'), 'hls')
+ self.assertEqual(refresh.get('view_url'), f"/api/v1/streams/{stream_id}/tokens/refresh")
+ self.assertEqual(refresh.get('ingest_url'), f"/api/v1/streams/{stream_id}/tokens/refresh")
+ self.assertEqual(ttl_seconds.get('ingest'), 3600)
+ self.assertEqual(ttl_seconds.get('view'), 900)
+
+ def test_stream_health_endpoint_returns_capabilities(self) -> None:
+ self._set_session('u-owner')
+ response = self.client.get('/ajax/streams/health')
+ self.assertEqual(response.status_code, 200)
+ payload = response.get_json() or {}
+ self.assertTrue(payload.get('success'))
+ health = payload.get('health') or {}
+ capabilities = payload.get('capabilities') or {}
+ self.assertTrue(health.get('stream_manager_ready'))
+ self.assertIn('ffmpeg_found', health)
+ self.assertIn('media', capabilities.get('supported_stream_kinds') or [])
+ self.assertIn('telemetry_sensor', [item.get('id') for item in (capabilities.get('recommended_profiles') or [])])
+
+ def test_create_stream_accepts_ui_metadata_for_future_domains(self) -> None:
+ self._set_session('u-owner')
+ response = self.client.post(
+ '/ajax/streams',
+ json={
+ 'channel_id': 'C1',
+ 'title': 'Humidity bus',
+ 'description': 'Warehouse sensor feed',
+ 'stream_kind': 'telemetry',
+ 'media_kind': 'data',
+ 'protocol': 'events-json',
+ 'auto_post': False,
+ 'stream_domain': 'sensor',
+ 'operator_profile': 'monitor',
+ 'viewer_layout': 'dense',
+ },
+ headers={'X-CSRFToken': 'csrf-test-token'},
+ )
+ self.assertEqual(response.status_code, 201)
+ payload = response.get_json() or {}
+ self.assertTrue(payload.get('success'))
+ stream = payload.get('stream') or {}
+ metadata = stream.get('metadata') or {}
+ self.assertEqual(stream.get('stream_kind'), 'telemetry')
+ self.assertEqual(metadata.get('stream_domain'), 'sensor')
+ self.assertEqual(metadata.get('operator_profile'), 'monitor')
+ self.assertEqual(metadata.get('viewer_layout'), 'dense')
+ self.assertEqual(metadata.get('created_via'), 'ui')
+
+ def test_create_stream_with_start_now_posts_live_attachment_status(self) -> None:
+ self._set_session('u-owner')
+ response = self.client.post(
+ '/ajax/streams',
+ json={
+ 'channel_id': 'C1',
+ 'title': 'Live room',
+ 'media_kind': 'video',
+ 'auto_post': True,
+ 'start_now': True,
+ },
+ headers={'X-CSRFToken': 'csrf-test-token'},
+ )
+ self.assertEqual(response.status_code, 201)
+ payload = response.get_json() or {}
+ self.assertTrue(payload.get('success'))
+ stream = payload.get('stream') or {}
+ self.assertEqual(stream.get('status'), 'live')
+ sent_attachments = self.channel_manager.send_message.call_args.kwargs.get('attachments') or []
+ self.assertTrue(sent_attachments)
+ self.assertEqual(sent_attachments[0].get('status'), 'live')
+
+ def test_owner_can_stop_stream_via_ui_endpoint(self) -> None:
+ self._set_session('u-owner')
+ stream_id = str((self.stream_row or {}).get('id') or '')
+ started, start_err = self.stream_manager.start_stream(stream_id, 'u-owner')
+ self.assertIsNone(start_err)
+ self.assertEqual((started or {}).get('status'), 'live')
+ response = self.client.post(
+ f'/ajax/streams/{stream_id}/stop',
+ headers={'X-CSRFToken': 'csrf-test-token'},
+ )
+ self.assertEqual(response.status_code, 200)
+ payload = response.get_json() or {}
+ self.assertTrue(payload.get('success'))
+ stream = payload.get('stream') or {}
+ self.assertEqual(stream.get('status'), 'stopped')
def test_non_manager_gets_not_found_style_response(self) -> None:
self._set_session('u-viewer')