diff --git a/CHANGELOG.md b/CHANGELOG.md index 57350ea..9630a47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,52 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +## [0.4.89] - 2026-03-15 + +### Added +- **Inline map and chart embeds** - OpenStreetMap links that include coordinates now render as true inline map iframes, and TradingView symbol links now render as inline chart widgets instead of remaining provider cards. + +### Fixed +- **Google Maps query-link detection** - Google Maps provider matching now catches query-form URLs such as `https://www.google.com/maps?q=Toronto`, so shared map links no longer silently miss the embed pipeline. +- **Google Maps restricted-key embeds** - Inline Google Maps embeds now send the referrer policy expected by browser-restricted Maps Embed API keys, so configured `CANOPY_GOOGLE_MAPS_EMBED_API_KEY` deployments can actually render the official iframe instead of failing authorization at load time. + +## [0.4.88] - 2026-03-15 + +### Added +- **Shared rich embed provider expansion** - Expanded the common rich embed renderer across post and message surfaces to support Vimeo, Loom, Spotify, SoundCloud, direct audio/video URLs, and safe provider cards for map and TradingView links while keeping the embed surface bounded away from arbitrary raw iframe HTML. + +### Fixed +- **Inline math dollar-sign hardening** - KaTeX inline dollar parsing now requires the content between `$...$` delimiters to actually look mathematical, reducing accidental formatting damage in finance-style posts that contain multiple currency values. +- **Embed detection inside generated markup** - Provider URL expansion now skips matches that are already inside generated HTML tags or attributes, preventing supported-provider URLs inside ordinary markdown links from being rewritten into broken embed markup. + +## [0.4.87] - 2026-03-15 + +### Fixed +- **Cross-peer stream card truth and camera teardown** - Channel message snapshots now reconcile stream-card statuses against current local or remote stream state so remote viewers stop seeing stale `Preparing` badges after a stream is live, stream lifecycle changes sync the stored stream attachment metadata for local invalidation, and the browser broadcaster now tears down temporary device-enumeration and preview capture streams so stopping or closing the panel actually releases the camera. + +## [0.4.86] - 2026-03-15 + +### Fixed +- **Truthful stream lifecycle controls** - `start_now` stream cards are now posted after the stream actually transitions live, browser broadcaster start/stop actions update the real stream lifecycle endpoints, and owner-facing stream cards/workspaces expose a proper stop action with status chips that reconcile against the current stream row instead of stale attachment metadata. + +## [0.4.85] - 2026-03-15 + +### Fixed +- **Streaming playback rate-limit carve-out** - HLS manifests, stream segments, telemetry event playback, and local stream-proxy reads now use a dedicated high-ceiling playback limiter instead of the generic `/api/` throttle, preventing valid live stream sessions from immediately failing with `429` responses under normal player polling. + +## [0.4.84] - 2026-03-15 + +### Added +- **Streaming runtime readiness and token refresh surfaces** - Added real `STREAM_MANAGER` bootstrap wiring, stream runtime health endpoints for API/UI callers, and a first-class stream token refresh path so longer live sessions can renew access without ad-hoc reissue flows. + +### Changed +- **Streaming operator UX and metadata structure** - Stream creation now uses a structured modal/profile flow, stream cards open into a reusable workspace shell, and stream attachments carry richer UI metadata such as `stream_domain`, `operator_profile`, and `viewer_layout`. +- **Truthful media stream defaults** - Newly created media streams now default `metadata.latency_mode` to `hls`, matching the currently implemented transport instead of overstating LL-HLS support. + +### Fixed +- **Actionable ingest diagnostics** - Empty manifest or segment uploads now return `empty_ingest_payload` with a proxy/upload hint instead of a generic size error. +- **Remote stream proxy hot-path churn** - Remote stream proxy origin resolution now uses a short-lived cache with shorter probe/fetch timeouts to avoid repeated synchronous rescans on hot requests. + ## [0.4.83] - 2026-03-14 ### Fixed diff --git a/README.md b/README.md index 8a60b62..9b51b3b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

- Version 0.4.83 + Version 0.4.89 Python 3.10+ Apache 2.0 License ChaCha20-Poly1305 @@ -23,7 +23,7 @@ Get Started · API Reference · Agent Guide · - Release Notes · + Release Notes · Windows Tray · Changelog

@@ -80,6 +80,9 @@ Most chat products treat AI as bolt-on automation hanging off webhooks or extern Recent user-facing changes reflected in the app and docs: +- **Inline map, chart, and rich media embeds** in `0.4.84`-`0.4.89`, adding first-class embed rendering for YouTube, Vimeo, Loom, Spotify, SoundCloud, direct audio/video URLs, OpenStreetMap inline maps, TradingView inline chart widgets, and key-aware Google Maps embeds with honest safe-card fallback when no API key is configured. +- **Math rendering hardening** in `0.4.88`, so inline dollar-sign math detection only activates for content that actually looks mathematical, preventing accidental KaTeX formatting damage on finance-style posts. +- **Truthful stream lifecycle** in `0.4.84`-`0.4.87`, ensuring stream cards reflect real start/stop state, remote viewers see accurate status instead of stale `Preparing` badges, browser broadcasters release the camera on stop or panel close, and playback endpoints use a dedicated rate limiter instead of the generic API throttle. - **Cross-peer channel update and inline-image repair** in `0.4.83`, so active channel threads refresh when a new message lands in the channel you already have open, plain-text `.ini`-style config snippets can be posted without structured-composer false positives, and peer-synced inline `file:` images remap cleanly to local attachment IDs. - **Channel live-update recovery hardening** in `0.4.82`, so channel-thread polling falls back more aggressively to direct snapshots and channel-scoped workspace events are explicitly visible to actual channel members with message-read permission. - **Rich media composition pass** in `0.4.81`, adding inline uploaded-image anchors with `![caption](file:FILE_ID)` plus validated attachment `layout_hint` gallery rendering (`grid`, `hero`, `strip`, `stack`) across channels, feed, and DMs. @@ -89,26 +92,15 @@ Recent user-facing changes reflected in the app and docs: - **Agent-focused workspace event feed** in `0.4.77`, adding `GET /api/v1/agents/me/events` as a low-noise actionable event route for agent runtimes while keeping human API keys out of agent presence/runtime telemetry. - **Incremental channel-state updates** in `0.4.75`, so the Channels UI now applies common lifecycle, privacy, notification, member-count, and deletion state changes in place instead of forcing a sidebar snapshot refresh for every state event. - **Channel thread cursor isolation hardening** in `0.4.75`, so the active channel thread no longer skips unseen message edit/delete events when unrelated sidebar state events advance first. -- **Request coordination reliability hardening** in `0.4.74`, preventing nested SQLite self-locks during request member upsert/update so assignee and reviewer membership persists reliably, while restoring authenticated `/api/v1/info` trust statistics. -- **Docs/version alignment refresh** across `0.4.78` to `0.4.83`, updating the README, operator guides, and current release copy so public-facing pointers match the latest development surface. +- **Docs/version alignment refresh** across `0.4.78` to `0.4.89`, updating the README, operator guides, and current release copy so public-facing pointers match the latest development surface. - **Workspace event journal rollout** across `0.4.69` to `0.4.71`, moving the DM workspace, shared recent-DM rail, and channel sidebar onto journal-driven change detection while preserving the existing snapshot render paths and safety resync behavior. - **Event-consumer race hardening** in `0.4.69` to `0.4.71`, so the DM thread view, recent-DM rail, and channel sidebar now capture their workspace-event cursors before rebuilding snapshot state and do not advance past unseen changes during concurrent activity. - **Structured block correction feedback** in `0.4.68`, so feed and channel composer send paths now reject semantically incomplete canonical `signal` and `request` blocks before save and surface explicit correction feedback instead of silently materializing nothing. - **Structured composer validation and feedback** across `0.4.67` and `0.4.68`, adding canonical block templates, malformed/alias validation, normalization actions, and post-send structured object summaries in the main feed and channel composers. - **UI and identity follow-up hardening** in `0.4.66`, carrying remote `account_type`, fixing local-profile sync eligibility, hardening channel reply buttons, preserving YouTube mini-player behavior, and treating `origin_peer == local_peer_id` as local in identity/admin UI. - **Managed large-attachment store v1** in `0.4.60`, introducing a fixed `10 MB` metadata-first sync threshold, admin-configurable external storage root, automatic/manual/paused download policy, peer-authorized remote fetch, and bounded UI controls for manual download when automatic caching is disabled. -- **DM delivery and classification hardening** in `0.4.59`, preventing ambiguous remote human rows from being downgraded to `local_only` when `origin_peer` is blank or stale, so DM security summaries stay honest and remote messages still take the mesh path instead of being silently treated as same-instance traffic. -- **DM search and messaging layout refinement** in `0.4.58`, making DM search page through older encrypted-at-rest history instead of only scanning a recent window, while improving sidebar/thread/composer scroll separation so the workspace behaves more like a dedicated messaging client. -- **Dead-connection send churn fix** in `0.4.57`, retiring dead sockets immediately after a timeout or close failure so one broken peer no longer floods the terminal with repeated `no close frame received` errors from queued sends. -- **Mesh connectivity reliability hardening** in `0.4.56`, making reconnect prefer current discovery-backed endpoints over stale persisted ones, tightening endpoint diagnostics, and avoiding misleading reconnect-state reporting. -- **DM recipient search and incremental refresh** in `0.4.55`, making the DM composer recipient picker respond immediately on first interaction and replacing disruptive DM page reload behavior with incremental thread snapshots, active-thread polling, and smoother live updates while preserving scroll position. -- **DM attachment parity, image paste, and security-indicator polish** in `0.4.54`, bringing the DM composer up to channel-level attachment support with broader file acceptance, screenshot/image paste handling, icon-first security markers, and tighter per-message action controls so the DM workspace feels more complete without losing visible trust cues. -- **DM E2E hardening and security markers** in `0.4.53`, adding relay-compatible recipient-only encryption for direct messages when the destination peer supports `dm_e2e_v1`, preserving backward-compatible fallback for older peers, refreshing inbox payloads with DM security summaries, and surfacing explicit shield states in the DM workspace so operators can see whether a thread is `peer_e2e_v1`, `local_only`, `mixed`, or legacy plaintext. -- **Sidebar recent DM contacts** in `0.4.52`, adding a shared left-rail Recent DMs card with avatars, unread counts, online-state indicators, and direct click-through back into the relevant DM thread and message anchor. -- **DM mobile layout & relay thread fixes** in `0.4.49`, making the DM workspace production-grade on phone/tablet breakpoints and fixing relayed/group DM threads that appeared in the conversation rail but not in the open thread pane due to alias identity mismatch. -- **DM workspace redesign** in `0.4.48`, replacing the flat Messages dump with a real conversation rail, group/direct thread views, grouped bubbles, inline reply previews, and a unified bottom composer. -- **Agent endpoint compatibility hardening** in `0.4.45`, restoring backward-compatible `/api` access and legacy claim/ack aliases for older agents while keeping `/api/v1` canonical. -- **Mesh connectivity durability** in `0.4.44`, including endpoint truth preservation, broader reconnect targeting, indefinite capped-backoff retries, and safer sync-rate handling during reconnect. +- **DM E2E hardening, workspace redesign, and security markers** across `0.4.48` to `0.4.59`, with relay-compatible E2E, conversation-first DM workspace, group threads, grouped bubbles, security indicators, attachment parity with channels, and DM search through encrypted-at-rest history. +- **Mesh connectivity durability and relay hardening** across `0.4.44` to `0.4.57`, with dead-connection retirement, discovery-backed reconnect, endpoint truth preservation, and indefinite capped-backoff retries. - **Windows tray packaging path** refreshed for `0.4.45`, with a documented PyInstaller bundle and optional Inno Setup installer for non-Python Windows users. - **Identity portability phase 1** in `0.4.36` for feature-flagged bootstrap grant workflows and principal sync. - **Relay, reconnect, private-channel recovery, and E2E hardening** across the `0.4.3x` line. @@ -260,9 +252,9 @@ Canopy is designed so agents collaborate under your control instead of leaking c |---|---| | Channels & DMs | Public/private channels and direct messages with local-first persistence, a conversation-first DM workspace, group threads, inline replies, grouped message bubbles, and DM security markers that distinguish peer E2E, local-only, mixed, and legacy plaintext threads. | | Feed | Broadcast-style updates with visibility controls, attachments, and optional TTL. | -| Rich media | Images/audio/video attachments, inline uploaded-image anchors with `file:FILE_ID`, responsive attachment gallery hints (`grid`, `hero`, `strip`, `stack`), and inline playback for common formats. | +| Rich media | Images/audio/video attachments, inline uploaded-image anchors with `file:FILE_ID`, responsive attachment gallery hints (`grid`, `hero`, `strip`, `stack`), inline playback for common formats, and shared rich embed rendering for YouTube, Vimeo, Loom, Spotify, SoundCloud, direct audio/video URLs, OpenStreetMap inline maps, TradingView inline charts, and key-aware Google Maps embeds. | | Spreadsheet sharing | Upload `.csv`, `.tsv`, `.xlsx`, and `.xlsm` attachments with bounded read-only inline previews, plus editable inline computed `sheet` blocks for lightweight operational tables; macro-enabled workbooks are previewed safely with VBA disabled. | -| Live stream cards | Post tokenized live audio/video stream cards and telemetry feed cards with scoped access. | +| Live stream cards | Post tokenized live audio/video stream cards and telemetry feed cards with scoped access, truthful start/stop lifecycle state across peers, browser-native broadcast with camera teardown, and dedicated playback rate limiting. | | Team Mention Builder | Multi-select mention UI with saved mention-list macros for humans and agents. | | Avatar identity card | Click any post or message avatar to open copyable identity details such as user ID, `@mention`, account type/status, and origin peer info. | | Search | Full-text search across channels, feed, and DMs. | @@ -525,7 +517,7 @@ Guides: [docs/CONNECT_FAQ.md](docs/CONNECT_FAQ.md) and [docs/PEER_CONNECT_GUIDE. | [docs/MENTIONS.md](docs/MENTIONS.md) | Mentions polling and SSE for agents | | [docs/WINDOWS_TRAY.md](docs/WINDOWS_TRAY.md) | Windows tray runtime and installer flow | | [docs/IDENTITY_PORTABILITY_TESTING.md](docs/IDENTITY_PORTABILITY_TESTING.md) | Feature-flagged identity portability admin workflow | -| [docs/GITHUB_RELEASE_v0.4.83.md](docs/GITHUB_RELEASE_v0.4.83.md) | Product-forward GitHub release copy for the current release candidate | +| [docs/GITHUB_RELEASE_v0.4.89.md](docs/GITHUB_RELEASE_v0.4.89.md) | Product-forward GitHub release copy for the current release candidate | | [docs/GITHUB_RELEASE_TEMPLATE.md](docs/GITHUB_RELEASE_TEMPLATE.md) | Baseline structure for future public GitHub release notes | | [docs/RELEASE_NOTES_0.4.0.md](docs/RELEASE_NOTES_0.4.0.md) | Historical publish-ready `0.4.0` release notes copy | | [docs/SECURITY_ASSESSMENT.md](docs/SECURITY_ASSESSMENT.md) | Threat model and security assessment | diff --git a/canopy/__init__.py b/canopy/__init__.py index d2e4620..9c819ad 100644 --- a/canopy/__init__.py +++ b/canopy/__init__.py @@ -11,7 +11,7 @@ Development: AI-assisted implementation (Claude, Codex, GitHub Copilot, Cursor IDE, Ollama) """ -__version__ = "0.4.83" +__version__ = "0.4.89" __protocol_version__ = 1 __author__ = "Canopy Contributors" __license__ = "Apache-2.0" diff --git a/canopy/api/routes.py b/canopy/api/routes.py index c710e84..af6950a 100644 --- a/canopy/api/routes.py +++ b/canopy/api/routes.py @@ -262,6 +262,10 @@ def _normalize_channel_attachments(raw_attachments: Any, file_manager: Any) -> l def create_api_blueprint() -> Blueprint: """Create and configure the API blueprint.""" api = Blueprint('api', __name__) + _stream_remote_base_cache: dict[str, tuple[float, str]] = {} + _stream_remote_base_cache_ttl_seconds = 30.0 + _stream_remote_probe_timeout_seconds = 2.0 + _stream_remote_fetch_timeout_seconds = 4.0 # Authentication decorator def require_auth(required_permission: Optional[Permission] = None, @@ -405,6 +409,39 @@ def _get_stream_manager() -> Any: def _get_db_manager() -> Any: return current_app.config.get('DB_MANAGER') + def _get_cached_stream_remote_base(stream_id: str) -> Optional[str]: + sid = str(stream_id or "").strip() + if not sid: + return None + cached = _stream_remote_base_cache.get(sid) + if not cached: + return None + cached_at, base = cached + if (time.time() - cached_at) > _stream_remote_base_cache_ttl_seconds: + _stream_remote_base_cache.pop(sid, None) + return None + return base + + def _set_cached_stream_remote_base(stream_id: str, base: Optional[str]) -> None: + sid = str(stream_id or "").strip() + normalized = str(base or "").strip().rstrip('/') + if not sid: + return + if not normalized: + _stream_remote_base_cache.pop(sid, None) + return + _stream_remote_base_cache[sid] = (time.time(), normalized) + + def _stream_ingest_error_response(error: str) -> tuple[Any, int]: + if error in {'not_found', 'manifest_not_found'}: + return jsonify({'error': 'Not found'}), 404 + if error == 'empty_ingest_payload': + return jsonify({ + 'error': error, + 'hint': 'possible_empty_upload_or_proxy_buffering_issue', + }), 400 + return jsonify({'error': error}), 400 + def _build_stream_attachment(stream_row: dict[str, Any]) -> dict[str, Any]: media_kind = str(stream_row.get('media_kind') or 'audio') stream_kind = str(stream_row.get('stream_kind') or ('telemetry' if media_kind == 'data' else 'media')) @@ -7895,6 +7932,11 @@ def create_stream_api(): return jsonify({'error': error}), 400 return jsonify({'error': error}), 403 + if start_now and stream_row: + started, start_err = stream_manager.start_stream(stream_row['id'], g.api_key_info.user_id) + if not start_err and started: + stream_row = started + posted_message_id = None if auto_post and stream_row: from ..core.channels import MessageType as ChannelMessageType @@ -7948,11 +7990,6 @@ def create_stream_api(): except Exception as bcast_err: logger.warning(f"Stream post broadcast failed (non-fatal): {bcast_err}") - if start_now and stream_row: - started, start_err = stream_manager.start_stream(stream_row['id'], g.api_key_info.user_id) - if not start_err and started: - stream_row = started - payload = { 'success': True, 'stream': stream_row, @@ -7979,6 +8016,26 @@ def get_stream_api(stream_id): logger.error(f"Get stream failed: {e}", exc_info=True) return jsonify({'error': 'Internal server error'}), 500 + @api.route('/streams/health', methods=['GET']) + @require_auth(Permission.READ_FEED) + def get_stream_health_api(): + stream_manager = _get_stream_manager() + if not stream_manager: + return jsonify({ + 'success': False, + 'health': { + 'stream_manager_ready': False, + 'ffmpeg_found': False, + 'ffprobe_found': False, + 'latency_mode_supported': 'unavailable', + }, + }), 503 + try: + return jsonify({'success': True, 'health': stream_manager.get_runtime_health()}) + except Exception as e: + logger.error(f"Stream health failed: {e}", exc_info=True) + return jsonify({'error': 'Internal server error'}), 500 + @api.route('/streams//start', methods=['POST']) @require_auth(Permission.WRITE_FEED) def start_stream_api(stream_id): @@ -8051,6 +8108,54 @@ def issue_stream_token_api(stream_id): logger.error(f"Issue stream token failed: {e}", exc_info=True) return jsonify({'error': 'Internal server error'}), 500 + @api.route('/streams//tokens/refresh', methods=['POST']) + @require_auth(Permission.READ_FEED) + def refresh_stream_token_api(stream_id): + stream_manager = _get_stream_manager() + if not stream_manager: + return jsonify({'error': 'Streaming unavailable'}), 503 + try: + data = request.get_json(silent=True) or {} + scope = str(data.get('scope') or 'view').strip().lower() + current_token = str(data.get('token') or '').strip() + ttl_seconds = data.get('ttl_seconds') + if scope == 'ingest' and Permission.WRITE_FEED not in set(getattr(g.api_key_info, 'permissions', set()) or set()): + return jsonify({'error': 'Invalid or insufficient permissions'}), 403 + token_payload, error = stream_manager.refresh_token( + stream_id=stream_id, + current_token=current_token, + scope=scope, + user_id=g.api_key_info.user_id, + ttl_seconds=ttl_seconds, + metadata={'issued_via': 'refresh_api'}, + ) + if error in {'not_found', 'not_authorized', 'invalid_token', 'expired_token', 'revoked_token'}: + return jsonify({'error': 'Not found'}), 404 + if error: + return jsonify({'error': error}), 400 + if not token_payload: + return jsonify({'error': 'token_refresh_failed'}), 500 + + stream_row = stream_manager.get_stream(stream_id) or {} + stream_kind = str(stream_row.get('stream_kind') or 'media').lower() + protocol = str(stream_row.get('protocol') or 'hls').lower() + token_q = quote_plus(str(token_payload.get('token') or '')) + if scope == 'view': + if stream_kind == 'telemetry' or protocol == 'events-json': + token_payload['playback_url'] = f"/api/v1/streams/{stream_id}/events?token={token_q}" + else: + token_payload['playback_url'] = f"/api/v1/streams/{stream_id}/manifest.m3u8?token={token_q}" + else: + if stream_kind == 'telemetry' or protocol == 'events-json': + token_payload['ingest_url'] = f"/api/v1/streams/{stream_id}/ingest/events?token={token_q}" + else: + token_payload['manifest_url'] = f"/api/v1/streams/{stream_id}/ingest/manifest?token={token_q}" + token_payload['segment_url_template'] = f"/api/v1/streams/{stream_id}/ingest/segments/seg%06d.ts?token={token_q}" + return jsonify({'success': True, **token_payload}) + except Exception as e: + logger.error(f"Refresh stream token failed: {e}", exc_info=True) + return jsonify({'error': 'Internal server error'}), 500 + @api.route('/streams//join', methods=['POST']) @require_auth(Permission.READ_FEED) def join_stream_api(stream_id): @@ -8112,9 +8217,7 @@ def ingest_stream_manifest_api(stream_id): payload = request.get_data(cache=False, as_text=False) err = stream_manager.store_manifest(stream_id=stream_id, manifest_bytes=payload or b'') if err: - if err in {'not_found', 'manifest_not_found'}: - return jsonify({'error': 'Not found'}), 404 - return jsonify({'error': err}), 400 + return _stream_ingest_error_response(err) # Transition to live on first successful ingest update. try: stream_manager.start_stream(stream_id, str(token_data.get('user_id') or '')) @@ -8146,9 +8249,7 @@ def ingest_stream_segment_api(stream_id, segment_name): segment_bytes=payload or b'', ) if err: - if err == 'not_found': - return jsonify({'error': 'Not found'}), 404 - return jsonify({'error': err}), 400 + return _stream_ingest_error_response(err) return jsonify({'success': True}) except Exception as e: logger.error(f"Ingest segment failed: {e}", exc_info=True) @@ -8203,6 +8304,9 @@ def _find_stream_remote_base(self_stream_id: str) -> Optional[str]: from urllib.request import urlopen as _urlopen from urllib.error import URLError as _URLError import json as _json + cached = _get_cached_stream_remote_base(self_stream_id) + if cached: + return cached db_manager = _get_db_manager() if not db_manager: return None @@ -8229,12 +8333,16 @@ def _find_stream_remote_base(self_stream_id: str) -> Optional[str]: for base in candidates: try: test_url = f"{base}/api/v1/streams/{self_stream_id}/manifest.m3u8" - with _urlopen(test_url, timeout=4) as resp: + with _urlopen(test_url, timeout=_stream_remote_probe_timeout_seconds) as resp: resp.read(1) + _set_cached_stream_remote_base(self_stream_id, base) return base except Exception: continue - return candidates[0] if candidates else None + if candidates: + _set_cached_stream_remote_base(self_stream_id, candidates[0]) + return candidates[0] + return None @api.route('/stream-proxy//manifest.m3u8', methods=['GET']) def stream_proxy_manifest_api(stream_id): @@ -8247,9 +8355,11 @@ def stream_proxy_manifest_api(stream_id): return jsonify({'error': 'Remote peer not found'}), 404 remote_url = f"{remote_base}/api/v1/streams/{stream_id}/manifest.m3u8" try: - with _urlopen(remote_url, timeout=8) as resp: + with _urlopen(remote_url, timeout=_stream_remote_fetch_timeout_seconds) as resp: raw = resp.read().decode('utf-8') + _set_cached_stream_remote_base(stream_id, remote_base) except _URLError as e: + _set_cached_stream_remote_base(stream_id, None) logger.warning(f"stream-proxy: failed to fetch {remote_url}: {e}") return jsonify({'error': 'Remote stream unreachable'}), 502 # Rewrite segment URLs to go through the local proxy too @@ -8296,10 +8406,12 @@ def stream_proxy_segment_api(stream_id, segment_name): return jsonify({'error': 'Not found'}), 404 remote_url = f"{remote_base}/api/v1/streams/{stream_id}/segments/{segment_name}" try: - with _urlopen(remote_url, timeout=8) as resp: + with _urlopen(remote_url, timeout=_stream_remote_fetch_timeout_seconds) as resp: data = resp.read() content_type = resp.headers.get('Content-Type', 'application/octet-stream') + _set_cached_stream_remote_base(stream_id, remote_base) except _URLError as e: + _set_cached_stream_remote_base(stream_id, None) logger.warning(f"stream-proxy segment: failed to fetch {remote_url}: {e}") return jsonify({'error': 'Not found'}), 404 return Response( diff --git a/canopy/core/app.py b/canopy/core/app.py index 3dd0c21..1104d49 100644 --- a/canopy/core/app.py +++ b/canopy/core/app.py @@ -31,6 +31,7 @@ from .feed import FeedManager from .tasks import TaskManager from .search import SearchManager +from .streams import StreamManager from ..security.api_keys import ApiKeyManager from ..security.trust import TrustManager from .messaging import ( @@ -257,6 +258,7 @@ def create_app(config: Optional[Config] = None) -> Flask: app.config['SECRET_KEY'] = config.secret_key app.config['DEBUG'] = config.debug app.config['TESTING'] = config.testing + app.config['GOOGLE_MAPS_EMBED_API_KEY'] = os.getenv('CANOPY_GOOGLE_MAPS_EMBED_API_KEY', '').strip() # Store config in app for access in routes app.config['CANOPY_CONFIG'] = config @@ -312,6 +314,19 @@ def create_app(config: Optional[Config] = None) -> Flask: app.config['CHANNEL_MANAGER'] = channel_manager logger.info("Channel manager initialized successfully") + logger.info("Initializing stream manager...") + streams_data_root = str(Path(config.storage.data_dir) if config.storage.data_dir else Path('./data')) + stream_manager = StreamManager( + db=db_manager, + channel_manager=channel_manager, + data_root=streams_data_root, + ) + app.config['STREAM_MANAGER'] = stream_manager + logger.info( + "Stream manager initialized successfully (storage_root=%s)", + stream_manager.storage_root, + ) + logger.info("Initializing feed manager...") feed_manager = FeedManager(db_manager, api_key_manager) app.config['FEED_MANAGER'] = feed_manager @@ -6522,6 +6537,27 @@ def prune(self, max_age: float = 3600.0) -> None: _login_limiter = _RateLimiter(rate=0.2, capacity=5) # Login: ~1 per 5s, burst 5 (per IP) _ui_ajax_limiter = _RateLimiter(rate=10, capacity=30) # UI AJAX: 10 req/s burst 30 (per IP/session) _p2p_limiter = _RateLimiter(rate=20, capacity=60) # Stricter P2P: 20 req/s burst 60 +_stream_playback_limiter = _RateLimiter(rate=60, capacity=240) # Playback/telemetry reads need a much higher ceiling than generic API calls + + +def _is_stream_playback_path(path: str) -> bool: + """Return True when the request path is a tokenized stream playback/proxy read.""" + normalized = str(path or "").strip() + if not normalized: + return False + stream_prefixes = ( + '/api/v1/streams/', + '/api/streams/', + '/api/v1/stream-proxy/', + '/api/stream-proxy/', + ) + if not normalized.startswith(stream_prefixes): + return False + return ( + normalized.endswith('/manifest.m3u8') + or '/segments/' in normalized + or normalized.endswith('/events') + ) def _install_rate_limiting(app: Flask) -> None: @@ -6547,6 +6583,8 @@ def _rate_limit_check(): elif '/files/upload' in path: key = _req.headers.get('X-API-Key', key) limiter = _upload_limiter + elif _is_stream_playback_path(path): + limiter = _stream_playback_limiter elif path.startswith('/api/'): key = _req.headers.get('X-API-Key', key) limiter = _api_limiter @@ -6571,6 +6609,7 @@ def _prune_buckets(response): _login_limiter.prune() _ui_ajax_limiter.prune() _p2p_limiter.prune() + _stream_playback_limiter.prune() return response diff --git a/canopy/core/channels.py b/canopy/core/channels.py index 755c701..b9a3f30 100644 --- a/canopy/core/channels.py +++ b/canopy/core/channels.py @@ -3781,6 +3781,73 @@ def update_message(self, message_id: str, user_id: str, content: str, logger.error(f"Failed to update channel message: {e}", exc_info=True) return False + def update_stream_attachment_status(self, stream_id: str, status: str) -> int: + """Update posted stream-card attachment statuses for a stream lifecycle change.""" + sid = str(stream_id or '').strip() + next_status = str(status or '').strip().lower() + if not sid or next_status not in {'created', 'live', 'stopped'}: + return 0 + changed_rows: List[Dict[str, str]] = [] + try: + with self.db.get_connection() as conn: + rows = conn.execute( + """ + SELECT id, channel_id, user_id, content, attachments + FROM channel_messages + WHERE attachments IS NOT NULL AND attachments != '[]' + """ + ).fetchall() + for row in rows: + try: + attachments = json.loads(row['attachments'] or '[]') + except Exception: + continue + if not isinstance(attachments, list): + continue + touched = False + updated_attachments: List[Dict[str, Any]] = [] + for attachment in attachments: + att = Message.normalize_attachment(attachment) + if not att: + continue + if str(att.get('stream_id') or '').strip() == sid: + if str(att.get('status') or '').strip().lower() != next_status: + att['status'] = next_status + touched = True + updated_attachments.append(att) + if not touched: + continue + conn.execute( + "UPDATE channel_messages SET attachments = ? WHERE id = ?", + (json.dumps(updated_attachments), row['id']), + ) + changed_rows.append({ + 'message_id': str(row['id'] or ''), + 'channel_id': str(row['channel_id'] or ''), + 'user_id': str(row['user_id'] or ''), + 'preview': (str(row['content'] or '').strip()[:160] or 'Attachment'), + }) + if changed_rows: + conn.commit() + for row in changed_rows: + if row['channel_id']: + self._emit_channel_user_event( + channel_id=row['channel_id'], + event_type=EVENT_CHANNEL_MESSAGE_EDITED, + actor_user_id=row['user_id'], + payload={ + 'message_id': row['message_id'], + 'preview': row['preview'], + 'reason': 'stream_status_updated', + 'stream_status': next_status, + }, + dedupe_suffix=f"stream_status_updated:{row['message_id']}:{next_status}", + ) + return len(changed_rows) + except Exception as e: + logger.error(f"Failed to update stream attachment status for {sid}: {e}", exc_info=True) + return 0 + def update_message_expiry(self, message_id: str, user_id: str, expires_at: Optional[Any] = None, ttl_seconds: Optional[int] = None, diff --git a/canopy/core/streams.py b/canopy/core/streams.py index ba40c17..63b056a 100644 --- a/canopy/core/streams.py +++ b/canopy/core/streams.py @@ -12,6 +12,7 @@ import logging import re import secrets +import shutil from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path @@ -105,6 +106,7 @@ class StreamManager: DEFAULT_TOKEN_TTL_SECONDS = 15 * 60 MIN_TOKEN_TTL_SECONDS = 30 MAX_TOKEN_TTL_SECONDS = 24 * 60 * 60 + DEFAULT_LATENCY_MODE = "hls" MAX_EVENT_PAYLOAD_CHARS = 512 * 1024 DEFAULT_EVENT_RETENTION_MAX = 5000 MAX_EVENT_RETENTION_MAX = 100000 @@ -196,6 +198,35 @@ def _ensure_tables(self) -> None: ) conn.commit() + def get_runtime_health(self) -> dict[str, Any]: + ffmpeg_path = shutil.which("ffmpeg") + ffprobe_path = shutil.which("ffprobe") + with self.db.get_connection() as conn: + counts = conn.execute( + """ + SELECT + COUNT(*) AS total_streams, + SUM(CASE WHEN status = 'live' THEN 1 ELSE 0 END) AS live_streams + FROM streams + """ + ).fetchone() + total_streams = int(counts["total_streams"] or 0) if counts else 0 + live_streams = int(counts["live_streams"] or 0) if counts else 0 + return { + "stream_manager_ready": True, + "storage_root": str(self.storage_root), + "ffmpeg_found": bool(ffmpeg_path), + "ffmpeg_path": ffmpeg_path, + "ffprobe_found": bool(ffprobe_path), + "ffprobe_path": ffprobe_path, + "default_token_ttl_seconds": self.DEFAULT_TOKEN_TTL_SECONDS, + "max_token_ttl_seconds": self.MAX_TOKEN_TTL_SECONDS, + "latency_mode_supported": self.DEFAULT_LATENCY_MODE, + "streams_total": total_streams, + "streams_live": live_streams, + "remote_proxy_mode": "sync_probe_cached", + } + def _extract_stream_kind(self, row: Any, metadata: Optional[dict[str, Any]] = None) -> str: meta = metadata or {} kind = str(meta.get("stream_kind") or "").strip().lower() @@ -355,7 +386,7 @@ def create_stream( safe_meta["stream_kind"] = kind if kind == "media": safe_meta.setdefault("ingest", "hls_push") - safe_meta.setdefault("latency_mode", "ll-hls") + safe_meta.setdefault("latency_mode", self.DEFAULT_LATENCY_MODE) else: safe_meta.setdefault("ingest", "event_push") safe_meta.setdefault("retention_max_events", self.DEFAULT_EVENT_RETENTION_MAX) @@ -494,7 +525,13 @@ def _set_status(self, stream_id: str, user_id: str, status: str) -> tuple[Option out = conn.execute("SELECT * FROM streams WHERE id = ?", (sid,)).fetchone() if not out: return None, "update_failed" - return self._row_to_stream(out).to_dict(), None + stream_payload = self._row_to_stream(out).to_dict() + try: + if hasattr(self.channel_manager, "update_stream_attachment_status"): + self.channel_manager.update_stream_attachment_status(sid, next_status) + except Exception as sync_err: + logger.warning(f"Failed to sync stream attachment status for {sid}: {sync_err}") + return stream_payload, None def start_stream(self, stream_id: str, user_id: str) -> tuple[Optional[dict[str, Any]], Optional[str]]: return self._set_status(stream_id, user_id, "live") @@ -573,6 +610,25 @@ def issue_token( "ttl_seconds": ttl, }, None + def revoke_token(self, token_id: str) -> Optional[str]: + tid = str(token_id or "").strip() + if not tid: + return "missing_token_id" + now = _db_ts(_utcnow()) + with self.db.get_connection() as conn: + row = conn.execute( + "SELECT id FROM stream_access_tokens WHERE id = ?", + (tid,), + ).fetchone() + if not row: + return "not_found" + conn.execute( + "UPDATE stream_access_tokens SET revoked_at = ? WHERE id = ?", + (now, tid), + ) + conn.commit() + return None + def validate_token( self, *, @@ -634,6 +690,44 @@ def validate_token( "metadata": meta, }, None + def refresh_token( + self, + *, + stream_id: str, + current_token: str, + scope: str, + user_id: str, + ttl_seconds: Optional[int] = None, + metadata: Optional[dict[str, Any]] = None, + ) -> tuple[Optional[dict[str, Any]], Optional[str]]: + sid = str(stream_id or "").strip() + uid = str(user_id or "").strip() + if not sid or not uid: + return None, "missing_identity" + token_data, token_err = self.validate_token( + stream_id=sid, + token=current_token, + scope=scope, + ) + if token_err or not token_data: + return None, token_err or "invalid_token" + token_owner = str(token_data.get("user_id") or "").strip() + if token_owner != uid: + return None, "not_authorized" + revoke_err = self.revoke_token(str(token_data.get("id") or "")) + if revoke_err and revoke_err != "not_found": + return None, revoke_err + merged_metadata = dict(metadata or {}) + merged_metadata["refresh_of"] = str(token_data.get("id") or "") + merged_metadata["refreshed_at"] = _utcnow().isoformat() + return self.issue_token( + stream_id=sid, + user_id=uid, + scope=scope, + ttl_seconds=ttl_seconds, + metadata=merged_metadata, + ) + def _load_stream_row(self, stream_id: str) -> Optional[Any]: sid = str(stream_id or "").strip() if not sid: @@ -652,7 +746,9 @@ def store_manifest( return "missing_stream_id" if not isinstance(manifest_bytes, (bytes, bytearray)): return "invalid_manifest" - if len(manifest_bytes) <= 0 or len(manifest_bytes) > self.MAX_MANIFEST_BYTES: + if len(manifest_bytes) <= 0: + return "empty_ingest_payload" + if len(manifest_bytes) > self.MAX_MANIFEST_BYTES: return "manifest_size_invalid" try: manifest_text = manifest_bytes.decode("utf-8") @@ -694,7 +790,9 @@ def store_segment( return "invalid_segment_name" if not isinstance(segment_bytes, (bytes, bytearray)): return "invalid_segment" - if len(segment_bytes) <= 0 or len(segment_bytes) > self.MAX_SEGMENT_BYTES: + if len(segment_bytes) <= 0: + return "empty_ingest_payload" + if len(segment_bytes) > self.MAX_SEGMENT_BYTES: return "segment_size_invalid" if not self._load_stream_row(sid): return "not_found" diff --git a/canopy/ui/routes.py b/canopy/ui/routes.py index d5ea3db..de29404 100644 --- a/canopy/ui/routes.py +++ b/canopy/ui/routes.py @@ -197,6 +197,64 @@ def _sort_key(url: str) -> int: return None +def _probe_remote_stream_manifest_live(stream_id: str, remote_base: Optional[str]) -> bool: + """Best-effort check whether a remote stream is actively serving a manifest.""" + base = str(remote_base or '').strip().rstrip('/') + sid = str(stream_id or '').strip() + if not base or not sid: + return False + try: + from urllib.request import urlopen as _urlopen + remote_url = f"{base}/api/v1/streams/{sid}/manifest.m3u8" + with _urlopen(remote_url, timeout=2.5) as resp: + resp.read(1) + return True + except Exception: + return False + + +def _refresh_stream_attachment_statuses( + attachments: list[dict[str, Any]], + *, + user_id: str, + stream_manager: Any, + db_manager: Any, + p2p_manager: Any, + status_cache: dict[str, str], +) -> None: + """Reconcile stream card attachments against current local or remote stream state.""" + for attachment in attachments: + if not isinstance(attachment, dict): + continue + if str(attachment.get('kind') or '').strip().lower() != 'stream' and not attachment.get('stream_id'): + continue + stream_id = str(attachment.get('stream_id') or '').strip() + if not stream_id: + continue + if stream_id in status_cache: + attachment['status'] = status_cache[stream_id] + continue + fallback_status = str(attachment.get('status') or 'created').strip().lower() + resolved_status = fallback_status if fallback_status in {'live', 'stopped'} else 'created' + try: + if stream_manager: + local_stream = stream_manager.get_stream_for_user(stream_id, user_id) + if local_stream: + local_status = str(local_stream.get('status') or '').strip().lower() + if local_status in {'created', 'live', 'stopped'}: + resolved_status = local_status + status_cache[stream_id] = resolved_status + attachment['status'] = resolved_status + continue + except Exception: + pass + remote = _resolve_p2p_stream(stream_id, db_manager, p2p_manager) if db_manager else None + if remote and _probe_remote_stream_manifest_live(stream_id, remote.get('remote_base')): + resolved_status = 'live' + status_cache[stream_id] = resolved_status + attachment['status'] = resolved_status + + def create_ui_blueprint() -> Blueprint: """Create and configure the UI blueprint.""" ui = Blueprint('ui', __name__, template_folder='templates', static_folder='static') @@ -1859,6 +1917,11 @@ def _build_admin_workspace_snapshot( 'last_event_fetch_at': None, 'last_event_cursor_seen': None, 'last_inbox_fetch_at': None, + 'event_subscription_source': 'default', + 'event_subscription_custom_enabled': False, + 'event_subscription_types': [], + 'event_subscription_count': 0, + 'event_subscription_updated_at': None, 'oldest_pending_inbox_at': None, 'oldest_pending_inbox_age_seconds': None, 'oldest_pending_inbox_age_text': None, @@ -3901,7 +3964,6 @@ def channels(): return render_template('channels.html', channels=channels, user_id=user_id, - config=config, peer_device_profiles=peer_device_profiles, local_device=local_device, local_peer_id=local_peer_id, @@ -9839,6 +9901,8 @@ def _user_display(uid: str) -> Optional[dict[str, Any]]: # Batch-check which messages the current user has liked msg_ids = [m.id for m in messages] user_liked_ids = set() + stream_manager = current_app.config.get('STREAM_MANAGER') + stream_status_cache: dict[str, str] = {} if interaction_manager: user_liked_ids = interaction_manager.get_user_liked_ids(msg_ids, user_id) @@ -9856,6 +9920,14 @@ def _user_display(uid: str) -> Optional[dict[str, Any]]: att['url'] = f"/files/{att['id']}" else: att['not_on_device'] = True # so UI can show "Not on this device yet" + _refresh_stream_attachment_statuses( + msg_dict.get('attachments') or [], + user_id=user_id, + stream_manager=stream_manager, + db_manager=db_manager, + p2p_manager=p2p_manager, + status_cache=stream_status_cache, + ) # Add like info if interaction_manager: try: @@ -10695,6 +10767,45 @@ def ajax_list_streams(): logger.error(f"List streams UI failed: {e}", exc_info=True) return jsonify({'success': False, 'error': 'Internal server error'}), 500 + @ui.route('/ajax/streams/health', methods=['GET']) + @require_login + def ajax_stream_health(): + """Return stream runtime readiness and UI capability hints for the signed-in user.""" + try: + stream_manager = current_app.config.get('STREAM_MANAGER') + if not stream_manager: + return jsonify({ + 'success': False, + 'error': 'Streaming unavailable', + 'health': { + 'stream_manager_ready': False, + 'ffmpeg_found': False, + 'ffprobe_found': False, + }, + 'capabilities': { + 'supported_stream_kinds': ['media', 'telemetry'], + 'supported_media_kinds': ['audio', 'video', 'data'], + 'domain_options': ['media', 'sensor', 'humanoid', 'robotics', 'automation'], + }, + }), 503 + return jsonify({ + 'success': True, + 'health': stream_manager.get_runtime_health(), + 'capabilities': { + 'supported_stream_kinds': ['media', 'telemetry'], + 'supported_media_kinds': ['audio', 'video', 'data'], + 'domain_options': ['media', 'sensor', 'humanoid', 'robotics', 'automation'], + 'recommended_profiles': [ + {'id': 'audio_ops', 'label': 'Audio briefing', 'stream_kind': 'media', 'media_kind': 'audio'}, + {'id': 'video_ops', 'label': 'Video watch', 'stream_kind': 'media', 'media_kind': 'video'}, + {'id': 'telemetry_sensor', 'label': 'Sensor feed', 'stream_kind': 'telemetry', 'media_kind': 'data'}, + ], + }, + }) + except Exception as e: + logger.error(f"Stream health UI failed: {e}", exc_info=True) + return jsonify({'success': False, 'error': 'Internal server error'}), 500 + @ui.route('/ajax/streams', methods=['POST']) @require_login def ajax_create_stream(): @@ -10717,6 +10828,25 @@ def ajax_create_stream(): relay_allowed = str(data.get('relay_allowed') or '').strip().lower() in {'1', 'true', 'yes', 'on'} auto_post = True if data.get('auto_post') is None else str(data.get('auto_post')).strip().lower() in {'1', 'true', 'yes', 'on'} start_now = str(data.get('start_now') or '').strip().lower() in {'1', 'true', 'yes', 'on'} + raw_metadata = data.get('metadata') + metadata = dict(raw_metadata) if isinstance(raw_metadata, dict) else {} + local_peer_id = None + try: + candidate_peer_id = p2p_manager.get_peer_id() if p2p_manager and hasattr(p2p_manager, 'get_peer_id') else None + candidate_peer_id = str(candidate_peer_id or '').strip() + local_peer_id = candidate_peer_id or None + except Exception: + local_peer_id = None + domain = str(metadata.get('stream_domain') or data.get('stream_domain') or '').strip().lower() + if domain: + metadata['stream_domain'] = domain + operator_profile = str(metadata.get('operator_profile') or data.get('operator_profile') or '').strip().lower() + if operator_profile: + metadata['operator_profile'] = operator_profile + viewer_layout = str(metadata.get('viewer_layout') or data.get('viewer_layout') or '').strip().lower() + if viewer_layout: + metadata['viewer_layout'] = viewer_layout + metadata['created_via'] = 'ui' stream_row, error = stream_manager.create_stream( channel_id=channel_id, @@ -10727,14 +10857,19 @@ def ajax_create_stream(): media_kind=media_kind, protocol=protocol, relay_allowed=relay_allowed, - origin_peer=(p2p_manager.get_peer_id() if p2p_manager else None), - metadata={'created_via': 'ui'}, + origin_peer=local_peer_id, + metadata=metadata, ) if error: if error in {'channel_not_found', 'not_channel_member'}: return jsonify({'success': False, 'error': 'Channel not found'}), 404 return jsonify({'success': False, 'error': error}), 400 + if start_now and stream_row: + started, start_err = stream_manager.start_stream(stream_row['id'], user_id) + if not start_err and started: + stream_row = started + posted_message_id = None if auto_post and stream_row: from ..core.channels import MessageType as ChannelMessageType @@ -10765,6 +10900,7 @@ def ajax_create_stream(): 'channel_id': str(stream_row.get('channel_id') or channel_id), 'created_by': str(stream_row.get('created_by') or user_id), 'relay_allowed': bool(stream_row.get('relay_allowed')), + 'metadata': dict(stream_row.get('metadata') or {}), 'host_addrs': _host_addrs, } post_content = str(data.get('post_content') or '').strip() @@ -10781,7 +10917,7 @@ def ajax_create_stream(): content=post_content, message_type=ChannelMessageType.FILE, attachments=[attachment], - origin_peer=(p2p_manager.get_peer_id() if p2p_manager else None), + origin_peer=local_peer_id, ) if message: posted_message_id = message.id @@ -10816,11 +10952,6 @@ def ajax_create_stream(): except Exception as bcast_err: logger.warning(f"Failed to broadcast stream post card: {bcast_err}") - if start_now and stream_row: - started, start_err = stream_manager.start_stream(stream_row['id'], user_id) - if not start_err and started: - stream_row = started - payload = {'success': True, 'stream': stream_row} if posted_message_id: payload['posted_message_id'] = posted_message_id @@ -11003,10 +11134,27 @@ def ajax_stream_setup(stream_id): stream_row = stream_manager.get_stream_for_user(stream_id, user_id) or {} stream_kind = str(stream_row.get('stream_kind') or 'media').lower() protocol = str(stream_row.get('protocol') or 'hls').lower() + health = stream_manager.get_runtime_health() ingest_tok = quote_plus(str(ingest_payload.get('token') or '')) view_tok = quote_plus(str(view_payload.get('token') or '')) base = f"/api/v1/streams/{stream_id}" + warnings: list[dict[str, str]] = [] + if stream_kind == 'media' and not bool(health.get('ffmpeg_found')): + warnings.append({ + 'code': 'ffmpeg_missing', + 'message': 'ffmpeg was not found on the Canopy host; the generated media ingest command will not run until ffmpeg is installed.', + }) + if int(ingest_payload.get('ttl_seconds') or 0) < 1800: + warnings.append({ + 'code': 'short_ingest_ttl', + 'message': 'The ingest token expires quickly; refresh it before using this for a longer live stream.', + }) + if int(view_payload.get('ttl_seconds') or 0) < 900: + warnings.append({ + 'code': 'short_view_ttl', + 'message': 'Viewer tokens are short-lived; use token refresh for longer monitoring sessions.', + }) if stream_kind == 'telemetry' or protocol == 'events-json': ingest_bundle = {'events_url': f"{base}/ingest/events?token={ingest_tok}"} @@ -11038,6 +11186,23 @@ def ajax_stream_setup(stream_id): 'commands': {'posix': posix_cmd, 'powershell': ps_cmd}, 'ingest_expires_at': ingest_payload.get('expires_at'), 'view_expires_at': view_payload.get('expires_at'), + 'ttl_seconds': { + 'ingest': ingest_payload.get('ttl_seconds'), + 'view': view_payload.get('ttl_seconds'), + }, + 'token_refresh': { + 'view_url': f"{base}/tokens/refresh", + 'ingest_url': f"{base}/tokens/refresh", + }, + 'preflight': { + 'stream_manager_ready': bool(health.get('stream_manager_ready')), + 'ffmpeg_found': bool(health.get('ffmpeg_found')), + 'ffprobe_found': bool(health.get('ffprobe_found')), + 'latency_mode_supported': health.get('latency_mode_supported'), + 'storage_root': health.get('storage_root'), + 'remote_proxy_mode': health.get('remote_proxy_mode'), + 'warnings': warnings, + }, }, }) except Exception as e: diff --git a/canopy/ui/static/js/canopy-main.js b/canopy/ui/static/js/canopy-main.js index 4209993..2980527 100644 --- a/canopy/ui/static/js/canopy-main.js +++ b/canopy/ui/static/js/canopy-main.js @@ -1142,33 +1142,641 @@ } } - function containsMathDelimiters(text) { + function escapeEmbedHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>'); + } + + function escapeEmbedAttr(value) { + return escapeEmbedHtml(value) + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function trimEmbedUrlTrailingPunctuation(rawUrl) { + let url = String(rawUrl || ''); + let trailing = ''; + while (url.length > 10 && /[).,;:!?\]}>]$/.test(url)) { + if (url.endsWith(')')) { + const opens = (url.match(/\(/g) || []).length; + const closes = (url.match(/\)/g) || []).length; + if (opens >= closes) break; + } + trailing = url.slice(-1) + trailing; + url = url.slice(0, -1); + } + return { url, trailing }; + } + + function buildEmbedCaption(text) { + if (!text) return ''; + return '
' + escapeEmbedHtml(text) + '
'; + } + + function buildIframeEmbedPreview(providerClass, src, title, options = {}) { + const safeSrc = escapeEmbedAttr(src); + const safeTitle = escapeEmbedAttr(title || 'Embedded content'); + const allow = escapeEmbedAttr(options.allow || 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'); + const extraClass = options.extraClass ? ' ' + escapeEmbedAttr(options.extraClass) : ''; + const frameClass = options.frameClass ? ' ' + escapeEmbedAttr(options.frameClass) : ''; + const heightStyle = options.height ? ' style="height:' + String(options.height).replace(/[^0-9.]/g, '') + 'px"' : ''; + const sandbox = options.sandbox ? ' sandbox="' + escapeEmbedAttr(options.sandbox) + '"' : ''; + const referrerPolicy = escapeEmbedAttr(options.referrerPolicy || 'strict-origin-when-cross-origin'); + const caption = buildEmbedCaption(options.caption || ''); + return ( + '
' + + '' + + caption + + '
' + ); + } + + function buildNativeMediaEmbed(providerClass, url, title, tagName, mimeType, options = {}) { + const safeUrl = escapeEmbedAttr(url); + const safeTitle = escapeEmbedAttr(title || 'Embedded media'); + const safeType = mimeType ? ' type="' + escapeEmbedAttr(mimeType) + '"' : ''; + const caption = buildEmbedCaption(options.caption || ''); + return ( + '
' + + '<' + tagName + ' controls preload="metadata" playsinline title="' + safeTitle + '">' + + '' + + 'Your browser does not support this media.' + + '' + + caption + + '
' + ); + } + + function buildProviderCardEmbed(providerClass, url, title, subtitle, iconClass, options = {}) { + const safeUrl = escapeEmbedAttr(url); + const safeTitle = escapeEmbedHtml(title || 'External content'); + const safeSubtitle = escapeEmbedHtml(subtitle || ''); + const safeIcon = escapeEmbedAttr(iconClass || 'bi-box-arrow-up-right'); + const providerLabel = options.providerLabel ? '' + escapeEmbedHtml(options.providerLabel) + '' : ''; + const note = options.note ? '
' + escapeEmbedHtml(options.note) + '
' : ''; + return ( + '' + ); + } + + function classifyAudioMime(ext) { + const normalized = String(ext || '').toLowerCase(); + if (normalized === 'mp3') return 'audio/mpeg'; + if (normalized === 'wav') return 'audio/wav'; + if (normalized === 'ogg') return 'audio/ogg'; + if (normalized === 'm4a') return 'audio/mp4'; + if (normalized === 'aac') return 'audio/aac'; + if (normalized === 'flac') return 'audio/flac'; + return ''; + } + + function classifyVideoMime(ext) { + const normalized = String(ext || '').toLowerCase(); + if (normalized === 'mp4' || normalized === 'm4v') return 'video/mp4'; + if (normalized === 'webm') return 'video/webm'; + if (normalized === 'ogv') return 'video/ogg'; + if (normalized === 'mov') return 'video/quicktime'; + return ''; + } + + function spotifyEmbedHeight(kind) { + if (kind === 'track' || kind === 'episode') return 152; + return 352; + } + + function isEmbedMatchInsideHtmlTag(html, matchIndex) { + const source = String(html || ''); + const index = Number(matchIndex); + if (!Number.isFinite(index) || index < 0) return false; + const lastTagOpen = source.lastIndexOf('<', index); + const lastTagClose = source.lastIndexOf('>', index); + return lastTagOpen > lastTagClose; + } + + function getCanopyEmbedThemeName() { + return canopyEmbedTheme() === 'light' ? 'light' : 'dark'; + } + + function safeUrlParse(rawUrl) { + try { + return new URL(String(rawUrl || ''), window.location.origin); + } catch (_) { + return null; + } + } + + function getGoogleMapsEmbedApiKey() { + if (!window.CANOPY_VARS) return ''; + return String(window.CANOPY_VARS.googleMapsEmbedApiKey || '').trim(); + } + + function extractGoogleMapsQuery(urlObj) { + if (!urlObj) return ''; + const query = urlObj.searchParams.get('q') || urlObj.searchParams.get('query') || ''; + if (query) return query.trim(); + const parts = urlObj.pathname.split('/').filter(Boolean); + const placeIdx = parts.indexOf('place'); + if (placeIdx >= 0 && parts[placeIdx + 1]) { + return decodeURIComponent(parts[placeIdx + 1]).trim(); + } + const atMatch = urlObj.pathname.match(/@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/); + if (atMatch) { + return atMatch[1] + ',' + atMatch[2]; + } + return ''; + } + + function buildGoogleMapsEmbedUrl(rawUrl) { + const apiKey = getGoogleMapsEmbedApiKey(); + if (!apiKey) return ''; + const urlObj = safeUrlParse(rawUrl); + const query = extractGoogleMapsQuery(urlObj); + if (!query) return ''; + return 'https://www.google.com/maps/embed/v1/search?key=' + encodeURIComponent(apiKey) + '&q=' + encodeURIComponent(query); + } + + function clampNumber(value, min, max) { + const num = Number(value); + if (!Number.isFinite(num)) return min; + return Math.min(max, Math.max(min, num)); + } + + function buildOsmBoundingBox(lat, lon, zoom) { + const safeLat = clampNumber(lat, -85, 85); + const safeLon = clampNumber(lon, -180, 180); + const safeZoom = clampNumber(zoom, 2, 18); + const lonDelta = 360 / Math.pow(2, safeZoom + 2); + const latDelta = 180 / Math.pow(2, safeZoom + 2); + const left = Math.max(-180, safeLon - lonDelta); + const right = Math.min(180, safeLon + lonDelta); + const bottom = Math.max(-85, safeLat - latDelta); + const top = Math.min(85, safeLat + latDelta); + return [left, bottom, right, top].join(','); + } + + function buildOpenStreetMapEmbedUrl(rawUrl) { + const urlObj = safeUrlParse(rawUrl); + if (!urlObj) return ''; + let lat = ''; + let lon = ''; + let zoom = ''; + + const hashMatch = String(urlObj.hash || '').match(/#map=(\d+)\/(-?\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)/); + if (hashMatch) { + zoom = hashMatch[1]; + lat = hashMatch[2]; + lon = hashMatch[3]; + } + if (!lat || !lon) { + lat = urlObj.searchParams.get('mlat') || ''; + lon = urlObj.searchParams.get('mlon') || ''; + } + if (!zoom) { + zoom = urlObj.searchParams.get('zoom') || '12'; + } + if (!lat || !lon) return ''; + const bbox = buildOsmBoundingBox(lat, lon, zoom); + return 'https://www.openstreetmap.org/export/embed.html?bbox=' + + encodeURIComponent(bbox) + + '&layer=mapnik&marker=' + + encodeURIComponent(String(lat) + ',' + String(lon)); + } + + function parseTradingViewSymbol(rawUrl) { + const urlObj = safeUrlParse(rawUrl); + if (!urlObj) return ''; + const path = String(urlObj.pathname || ''); + const symbolMatch = path.match(/\/symbols\/([A-Za-z0-9._-]+)(?:\/)?/i); + if (symbolMatch && symbolMatch[1]) { + const rawSymbol = symbolMatch[1].replace(/\/+$/, ''); + if (rawSymbol.includes('-')) { + const idx = rawSymbol.indexOf('-'); + const exchange = rawSymbol.slice(0, idx).toUpperCase(); + const symbol = rawSymbol.slice(idx + 1).toUpperCase(); + if (exchange && symbol) return exchange + ':' + symbol; + } + return rawSymbol.toUpperCase(); + } + const tvSymbol = urlObj.searchParams.get('symbol') || urlObj.searchParams.get('ticker') || ''; + return tvSymbol.trim().toUpperCase(); + } + + function buildTradingViewEmbedUrl(rawUrl) { + const symbol = parseTradingViewSymbol(rawUrl); + if (!symbol) return ''; + const params = new URLSearchParams({ + symbol: symbol, + interval: 'D', + symboledit: '1', + saveimage: '0', + toolbarbg: getCanopyEmbedThemeName() === 'light' ? 'f8fafc' : '0f172a', + theme: getCanopyEmbedThemeName(), + style: '1', + withdateranges: '1', + hideideas: '1', + locale: 'en', + }); + params.set('utm_source', window.location.hostname || 'canopy'); + params.set('utm_medium', 'embed'); + params.set('utm_campaign', 'canopy'); + return 'https://s.tradingview.com/widgetembed/?' + params.toString(); + } + + const RICH_EMBED_PROVIDERS = [ + { + key: 'youtube', + pattern: /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([\w-]{11})(?:[&?]\S*)?/g, + render(match, videoId) { + return buildIframeEmbedPreview( + 'youtube-embed', + 'https://www.youtube-nocookie.com/embed/' + videoId + '?enablejsapi=1&playsinline=1&rel=0&origin=' + encodeURIComponent(window.location.origin), + 'YouTube video ' + videoId, + { caption: 'YouTube' } + ); + }, + }, + { + key: 'vimeo', + pattern: /https?:\/\/(?:www\.)?vimeo\.com\/(?:video\/)?(\d+)(?:[/?#]\S*)?/g, + render(match, videoId) { + const parts = trimEmbedUrlTrailingPunctuation(match); + return { + html: buildIframeEmbedPreview( + 'vimeo-embed', + 'https://player.vimeo.com/video/' + encodeURIComponent(videoId), + 'Vimeo video ' + videoId, + { caption: 'Vimeo' } + ), + trailing: parts.trailing, + }; + }, + }, + { + key: 'loom', + pattern: /https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([A-Za-z0-9]+)(?:\?\S*)?/g, + render(match, shareId) { + const parts = trimEmbedUrlTrailingPunctuation(match); + return { + html: buildIframeEmbedPreview( + 'loom-embed', + 'https://www.loom.com/embed/' + encodeURIComponent(shareId), + 'Loom recording ' + shareId, + { caption: 'Loom' } + ), + trailing: parts.trailing, + }; + }, + }, + { + key: 'spotify', + pattern: /https?:\/\/open\.spotify\.com\/(track|album|playlist|episode|show|artist)\/([A-Za-z0-9]+)(?:\?\S*)?/g, + render(match, kind, entityId) { + const parts = trimEmbedUrlTrailingPunctuation(match); + return { + html: buildIframeEmbedPreview( + 'spotify-embed', + 'https://open.spotify.com/embed/' + encodeURIComponent(kind) + '/' + encodeURIComponent(entityId) + '?utm_source=generator', + 'Spotify ' + kind, + { + caption: 'Spotify', + height: spotifyEmbedHeight(kind), + allow: 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture', + extraClass: 'fixed-height-embed', + } + ), + trailing: parts.trailing, + }; + }, + }, + { + key: 'soundcloud', + pattern: /https?:\/\/(?:www\.)?soundcloud\.com\/[^\s<"]+/g, + render(match) { + const parts = trimEmbedUrlTrailingPunctuation(match); + return { + html: buildIframeEmbedPreview( + 'soundcloud-embed', + 'https://w.soundcloud.com/player/?url=' + encodeURIComponent(parts.url) + '&color=%2359de89&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=false', + 'SoundCloud audio', + { + caption: 'SoundCloud', + height: 166, + allow: 'autoplay', + extraClass: 'fixed-height-embed', + } + ), + trailing: parts.trailing, + }; + }, + }, + { + key: 'x', + pattern: /https?:\/\/(?:www\.)?(?:x\.com|twitter\.com)\/(?:([\w]+)\/status\/|i\/web\/status\/|i\/status\/)(\d+)(?:\?\S*)?/g, + render(match, username, statusId) { + const url = username + ? ('https://x.com/' + username + '/status/' + statusId) + : ('https://x.com/i/web/status/' + statusId); + const label = username ? ('@' + username) : 'X post'; + return ( + '' + ); + }, + }, + { + key: 'google_maps', + pattern: /https?:\/\/(?:www\.)?(?:google\.[^\/]+\/maps(?:[/?#][^\s<"]*)?|maps\.google\.[^\/]+\/?[^\s<"]*|maps\.app\.goo\.gl\/?[^\s<"]*)/g, + render(match) { + const parts = trimEmbedUrlTrailingPunctuation(match); + const embedUrl = buildGoogleMapsEmbedUrl(parts.url); + if (embedUrl) { + return { + html: buildIframeEmbedPreview( + 'map-embed google-maps-embed', + embedUrl, + 'Google Maps', + { + caption: 'Google Maps', + height: 320, + allow: 'geolocation', + referrerPolicy: 'no-referrer-when-downgrade', + extraClass: 'fixed-height-embed map-service-embed', + } + ), + trailing: parts.trailing, + }; + } + return { + html: buildProviderCardEmbed( + 'map-card-embed', + parts.url, + 'Map link', + 'Open this location in Google Maps.', + 'bi-geo-alt-fill', + { + providerLabel: 'Google Maps', + note: getGoogleMapsEmbedApiKey() + ? 'Open this location in Google Maps.' + : 'Inline Google Maps requires CANOPY_GOOGLE_MAPS_EMBED_API_KEY; showing a safe card instead.', + } + ), + trailing: parts.trailing, + }; + }, + }, + { + key: 'openstreetmap', + pattern: /https?:\/\/(?:www\.)?openstreetmap\.org\/[^\s<"]+/g, + render(match) { + const parts = trimEmbedUrlTrailingPunctuation(match); + const embedUrl = buildOpenStreetMapEmbedUrl(parts.url); + if (embedUrl) { + return { + html: buildIframeEmbedPreview( + 'map-embed openstreetmap-embed', + embedUrl, + 'OpenStreetMap', + { + caption: 'OpenStreetMap', + height: 320, + extraClass: 'fixed-height-embed map-service-embed', + } + ), + trailing: parts.trailing, + }; + } + return { + html: buildProviderCardEmbed( + 'map-card-embed', + parts.url, + 'Map link', + 'Open this location in OpenStreetMap.', + 'bi-map', + { providerLabel: 'OpenStreetMap', note: 'Preview card for shared map context.' } + ), + trailing: parts.trailing, + }; + }, + }, + { + key: 'tradingview', + pattern: /https?:\/\/(?:www\.)?tradingview\.com\/[^\s<"]+/g, + render(match) { + const parts = trimEmbedUrlTrailingPunctuation(match); + const embedUrl = buildTradingViewEmbedUrl(parts.url); + if (embedUrl) { + return { + html: buildIframeEmbedPreview( + 'tradingview-embed', + embedUrl, + 'TradingView chart', + { + caption: 'TradingView', + height: 360, + extraClass: 'fixed-height-embed chart-service-embed', + } + ), + trailing: parts.trailing, + }; + } + return { + html: buildProviderCardEmbed( + 'tradingview-card-embed', + parts.url, + 'TradingView chart', + 'Open the live chart or symbol page in TradingView.', + 'bi-graph-up-arrow', + { providerLabel: 'TradingView', note: 'Official TradingView widgets exist; this safe card keeps the channel lightweight.' } + ), + trailing: parts.trailing, + }; + }, + }, + { + key: 'direct_video', + pattern: /https?:\/\/[^\s<"]+\.(mp4|webm|ogv|mov|m4v)(?:\?\S*)?/gi, + render(match, ext) { + const parts = trimEmbedUrlTrailingPunctuation(match); + return { + html: buildNativeMediaEmbed( + 'native-video-embed', + parts.url, + 'Embedded video', + 'video', + classifyVideoMime(ext), + { caption: 'Direct video' } + ), + trailing: parts.trailing, + }; + }, + }, + { + key: 'direct_audio', + pattern: /https?:\/\/[^\s<"]+\.(mp3|wav|ogg|m4a|aac|flac)(?:\?\S*)?/gi, + render(match, ext) { + const parts = trimEmbedUrlTrailingPunctuation(match); + return { + html: buildNativeMediaEmbed( + 'native-audio-embed', + parts.url, + 'Embedded audio', + 'audio', + classifyAudioMime(ext), + { caption: 'Direct audio' } + ), + trailing: parts.trailing, + }; + }, + }, + ]; + + function collectProviderEmbeds(html) { + const embeds = []; + const placeholderPrefix = '\x00EMB_'; + + RICH_EMBED_PROVIDERS.forEach(provider => { + html = html.replace(provider.pattern, function() { + const match = arguments[0]; + const matchIndex = arguments[arguments.length - 2]; + if (isEmbedMatchInsideHtmlTag(html, matchIndex)) { + return match; + } + const rendered = provider.render.apply(null, arguments); + if (!rendered) return arguments[0]; + const htmlValue = typeof rendered === 'string' ? rendered : rendered.html; + if (!htmlValue) return arguments[0]; + const idx = embeds.length; + embeds.push(htmlValue); + const trailing = typeof rendered === 'string' ? '' : (rendered.trailing || ''); + return placeholderPrefix + idx + '\x00' + trailing; + }); + }); + + return { html, embeds, placeholderPrefix }; + } + + function isEscapedMathDelimiter(value, index) { + let slashCount = 0; + for (let j = index - 1; j >= 0 && value[j] === '\\'; j--) { + slashCount += 1; + } + return (slashCount % 2) === 1; + } + + function hasExplicitMathDelimiters(text) { if (!text) return false; const value = String(text); - if (value.indexOf('$') !== -1) { - let inlineCount = 0; - let blockCount = 0; - for (let i = 0; i < value.length; i++) { - if (value[i] !== '$') continue; - let slashCount = 0; - for (let j = i - 1; j >= 0 && value[j] === '\\'; j--) { - slashCount += 1; + if (value.indexOf('\\(') !== -1 && value.indexOf('\\)') !== -1) return true; + if (value.indexOf('\\[') !== -1 && value.indexOf('\\]') !== -1) return true; + for (let i = 0; i < value.length - 1; i++) { + if (value[i] === '$' && value[i + 1] === '$' && !isEscapedMathDelimiter(value, i)) { + for (let j = i + 2; j < value.length - 1; j++) { + if (value[j] === '$' && value[j + 1] === '$' && !isEscapedMathDelimiter(value, j)) { + return true; + } } - if ((slashCount % 2) === 1) continue; - if (value[i + 1] === '$') { - blockCount += 1; - i += 1; - } else { - inlineCount += 1; + } + } + return false; + } + + function isLikelyMathInlineContent(content) { + const trimmed = String(content || '').trim(); + if (!trimmed || trimmed.length > 120 || /[\r\n]/.test(trimmed)) return false; + + if (/^\$?[\d,]+(?:\.\d+)?(?:\s*(?:k|m|mm|bn|b|t|%))?(?:\s*(?:usd|cad|eur|gbp))?$/i.test(trimmed)) { + return false; + } + if (/^[A-Z]{1,8}\s+\$?[\d,]+(?:\.\d+)?(?:\s*%)?$/i.test(trimmed)) { + return false; + } + if (/^[\d,.\s]+(?:to|vs|at)\s+[\d,.\s]+$/i.test(trimmed)) { + return false; + } + + const hasLatexCommand = /\\[A-Za-z]+/.test(trimmed); + const hasBinaryOperator = /(?:\d|[A-Za-z)}\]])\s*[-+*=<>/^_]\s*(?:\d|[A-Za-z({[])/.test(trimmed); + const hasStructuredMath = /[_^{}]/.test(trimmed); + const hasMathKeywords = /\b(?:sin|cos|tan|log|ln|max|min|sum|prod|int|lim)\b/.test(trimmed); + + return hasLatexCommand || hasBinaryOperator || hasStructuredMath || hasMathKeywords; + } + + function hasLikelyInlineMath(text) { + if (!text || String(text).indexOf('$') === -1) return false; + const value = String(text); + for (let i = 0; i < value.length; i++) { + if (value[i] !== '$' || isEscapedMathDelimiter(value, i)) continue; + if (value[i + 1] === '$') { + i += 1; + continue; + } + for (let j = i + 1; j < value.length; j++) { + if (value[j] !== '$' || isEscapedMathDelimiter(value, j)) continue; + if (value[j - 1] === '$' || value[j + 1] === '$') continue; + if (isLikelyMathInlineContent(value.slice(i + 1, j))) { + return true; } + i = j; + break; } - if (blockCount >= 2 || inlineCount >= 2) return true; } - if (value.indexOf('\\(') !== -1 && value.indexOf('\\)') !== -1) return true; - if (value.indexOf('\\[') !== -1 && value.indexOf('\\]') !== -1) return true; return false; } + function buildMathDelimitersForText(text) { + const value = String(text || ''); + const delimiters = []; + if (value.indexOf('$$') !== -1) { + delimiters.push({ left: '$$', right: '$$', display: true }); + } + if (value.indexOf('\\[') !== -1 && value.indexOf('\\]') !== -1) { + delimiters.push({ left: '\\[', right: '\\]', display: true }); + } + if (value.indexOf('\\(') !== -1 && value.indexOf('\\)') !== -1) { + delimiters.push({ left: '\\(', right: '\\)', display: false }); + } + if (hasLikelyInlineMath(value)) { + delimiters.push({ left: '$', right: '$', display: false }); + } + return delimiters; + } + + function containsMathDelimiters(text) { + return hasExplicitMathDelimiters(text) || hasLikelyInlineMath(text); + } + function renderMathInElementSafe(root) { if (!root || typeof window === 'undefined' || typeof window.renderMathInElement !== 'function') { return false; @@ -1176,15 +1784,11 @@ const scope = (root instanceof Element || root instanceof Document) ? root : null; if (!scope) return false; const sourceText = scope.textContent || ''; - if (!containsMathDelimiters(sourceText)) return false; + const delimiters = buildMathDelimitersForText(sourceText); + if (!delimiters.length) return false; try { window.renderMathInElement(scope, { - delimiters: [ - { left: '$$', right: '$$', display: true }, - { left: '\\[', right: '\\]', display: true }, - { left: '\\(', right: '\\)', display: false }, - { left: '$', right: '$', display: false } - ], + delimiters: delimiters, throwOnError: false, strict: 'ignore', trust: false, @@ -2225,47 +2829,10 @@ }); html = html.replace(/\n/g, '
'); - // Collect embeds separately so we can grid them if >1 - const embeds = []; - const EMBED_PLACEHOLDER = '\x00EMB_'; - - // --- YouTube embeds --- - const ytRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([\w-]{11})(?:[&?]\S*)?/g; - html = html.replace(ytRegex, function(match, videoId) { - const idx = embeds.length; - embeds.push('
' + - '
'); - return EMBED_PLACEHOLDER + idx + '\x00'; - }); - - // --- X / Twitter embeds --- - const xRegex = /https?:\/\/(?:www\.)?(?:x\.com|twitter\.com)\/(?:([\w]+)\/status\/|i\/web\/status\/|i\/status\/)(\d+)(?:\?\S*)?/g; - html = html.replace(xRegex, function(match, username, statusId) { - const url = username - ? ('https://x.com/' + username + '/status/' + statusId) - : ('https://x.com/i/web/status/' + statusId); - const label = username ? ('@' + username) : 'X post'; - const idx = embeds.length; - embeds.push('
' + - '
' + - '
' + - '' + - '
' + - '' + label + '' + - '
View post on X
' + - '
' + - '' + - '
' + - '
' + - '
' + - '
'); - return EMBED_PLACEHOLDER + idx + '\x00'; - }); + const embedState = collectProviderEmbeds(html); + html = embedState.html; + const embeds = embedState.embeds; + const EMBED_PLACEHOLDER = embedState.placeholderPrefix; // --- Generic URL linkification --- // Strip trailing punctuation that's likely not part of the URL (e.g. trailing ) , . ; : ! ?) diff --git a/canopy/ui/templates/base.html b/canopy/ui/templates/base.html index 6cb2149..3b8633c 100644 --- a/canopy/ui/templates/base.html +++ b/canopy/ui/templates/base.html @@ -2450,20 +2450,140 @@ border-radius: 10px; overflow: hidden; background: var(--canopy-bg-secondary, #1a1a2e); + position: relative; + isolation: isolate; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16); } - .youtube-embed iframe { + .embed-preview::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 3px; + background: linear-gradient(90deg, var(--embed-accent, var(--canopy-primary, #59de89)), transparent 72%); + opacity: 0.92; + z-index: 1; + } + .iframe-embed iframe, + .native-media-embed video, + .native-media-embed audio { width: 100%; - aspect-ratio: 16/9; border: none; + display: block; + } + .iframe-embed iframe { + aspect-ratio: 16/9; border-radius: 10px; + background: #000; + } + .fixed-height-embed iframe { + aspect-ratio: auto; + min-height: 152px; + } + .map-service-embed iframe { + min-height: 320px; + background: linear-gradient(180deg, rgba(52, 211, 153, 0.08), rgba(15, 23, 42, 0.24)); + } + .chart-service-embed iframe { + min-height: 360px; + background: linear-gradient(180deg, rgba(79, 140, 255, 0.08), rgba(15, 23, 42, 0.24)); + } + .native-media-embed { + padding: 12px; + } + .native-media-embed video { + border-radius: 12px; + background: #000; + } + .native-media-embed audio { + min-height: 54px; + } + .provider-card-embed { + padding: 0; + } + .provider-embed-card { display: block; + color: inherit; + text-decoration: none; + padding: 14px 16px; + transition: background 0.2s ease, border-color 0.2s ease; + } + .provider-embed-card:hover { + background: var(--canopy-bg-tertiary, rgba(255,255,255,0.05)); + color: inherit; + text-decoration: none; + } + .provider-embed-card:focus-visible { + outline: 2px solid var(--embed-accent, var(--canopy-primary, #59de89)); + outline-offset: -2px; + } + .provider-embed-head { + display: flex; + align-items: flex-start; + gap: 12px; + } + .provider-embed-icon { + width: 40px; + height: 40px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(89, 222, 137, 0.12); + color: var(--canopy-primary, #59de89); + flex: 0 0 auto; + } + .provider-embed-icon i { + font-size: 1.1rem; + } + .provider-embed-copy { + min-width: 0; + flex: 1 1 auto; + } + .embed-provider-pill { + display: inline-flex; + align-items: center; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--canopy-primary, #59de89); + margin-bottom: 4px; + } + .provider-embed-title { + font-weight: 700; + color: var(--canopy-text-primary, #f8fafc); + line-height: 1.35; + overflow-wrap: anywhere; + } + .provider-embed-subtitle, + .embed-provider-note, + .embed-provider-caption { + color: var(--canopy-text-secondary, rgba(255,255,255,0.72)); + font-size: 0.9rem; + line-height: 1.45; + overflow-wrap: anywhere; + } + .provider-embed-subtitle { + margin-top: 2px; + } + .embed-provider-note { + margin-top: 6px; + } + .embed-provider-caption { + padding: 10px 12px 12px; + border-top: 1px solid var(--canopy-border, rgba(255,255,255,0.1)); + background: rgba(255,255,255,0.02); + } + .provider-embed-open { + color: var(--canopy-text-secondary, rgba(255,255,255,0.65)); + flex: 0 0 auto; + margin-top: 2px; } .x-embed { padding: 0; } .x-embed .x-embed-card { padding: 12px 16px; - cursor: pointer; transition: background 0.2s ease; } .x-embed .x-embed-card:hover { @@ -2483,6 +2603,20 @@ opacity: 0.4; font-size: 0.85rem; } + .youtube-embed { --embed-accent: #ff4e45; } + .vimeo-embed { --embed-accent: #1ab7ea; } + .loom-embed { --embed-accent: #7c3aed; } + .spotify-embed { --embed-accent: #1db954; } + .soundcloud-embed { --embed-accent: #ff7700; } + .x-embed { --embed-accent: #9ca3af; } + .map-card-embed, + .map-embed, + .google-maps-embed, + .openstreetmap-embed { --embed-accent: #34d399; } + .tradingview-card-embed, + .tradingview-embed { --embed-accent: #4f8cff; } + .native-video-embed { --embed-accent: #ef4444; } + .native-audio-embed { --embed-accent: #f59e0b; } /* Channel message code blocks with copy button */ .channel-code-wrap { position: relative; @@ -5454,6 +5588,7 @@ profileTheme: {{ (profile.theme_preference if profile else 'dark')|tojson }}, localUserId: {{ session.get('user_id')|tojson }}, localPeerId: {{ sidebar_local_peer_id|tojson if sidebar_local_peer_id is defined else 'null' }}, + googleMapsEmbedApiKey: {{ config.get('GOOGLE_MAPS_EMBED_API_KEY', '')|tojson }}, urls: { feed: {{ url_for('ui.feed')|tojson }}, channels: {{ url_for('ui.channels')|tojson }}, diff --git a/canopy/ui/templates/channels.html b/canopy/ui/templates/channels.html index 1e7cd66..cc20a1b 100644 --- a/canopy/ui/templates/channels.html +++ b/canopy/ui/templates/channels.html @@ -1619,6 +1619,234 @@ } } + .stream-card { + background: linear-gradient(145deg, rgba(15, 27, 42, 0.97), rgba(9, 18, 29, 0.95)); + border: 1px solid rgba(67, 98, 132, 0.52); + box-shadow: 0 18px 32px rgba(0, 0, 0, 0.18); + } + + .stream-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.9rem; + } + + .stream-card-identity { + display: flex; + align-items: flex-start; + gap: 0.75rem; + min-width: 0; + } + + .stream-card-icon { + width: 2.4rem; + height: 2.4rem; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(51, 195, 140, 0.16); + color: #aef7d1; + flex: 0 0 auto; + } + + .stream-card-title { + font-weight: 700; + line-height: 1.2; + color: #f0fbf6; + } + + .stream-card-subtitle { + color: rgba(220, 239, 232, 0.76); + font-size: 0.84rem; + margin-top: 0.15rem; + } + + .stream-meta-row, + .stream-card-actions { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + } + + .stream-meta-chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.22rem 0.58rem; + border-radius: 999px; + background: rgba(95, 132, 167, 0.14); + border: 1px solid rgba(82, 118, 153, 0.28); + color: rgba(236, 246, 243, 0.92); + font-size: 0.74rem; + font-weight: 600; + } + + .stream-meta-chip[data-state="live"] { + background: rgba(196, 44, 71, 0.2); + border-color: rgba(230, 94, 116, 0.4); + color: #ffd9de; + } + + .stream-meta-chip[data-state="telemetry"] { + background: rgba(64, 162, 214, 0.14); + border-color: rgba(64, 162, 214, 0.3); + color: #d9f2ff; + } + + .stream-workspace { + margin-top: 0.9rem; + border-radius: 16px; + border: 1px solid rgba(69, 97, 124, 0.54); + background: linear-gradient(180deg, rgba(10, 17, 27, 0.96), rgba(12, 20, 31, 0.92)); + overflow: hidden; + } + + .stream-workspace-shell { + display: grid; + grid-template-columns: minmax(0, 1.7fr) minmax(280px, 1fr); + gap: 1rem; + padding: 1rem; + } + + .stream-panel-block { + border-radius: 14px; + border: 1px solid rgba(69, 97, 124, 0.42); + background: rgba(15, 27, 41, 0.86); + padding: 0.9rem; + } + + .stream-panel-title { + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(170, 226, 195, 0.88); + margin-bottom: 0.55rem; + } + + .stream-status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; + } + + .stream-kv { + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); + padding: 0.6rem 0.7rem; + } + + .stream-kv-label { + font-size: 0.72rem; + color: rgba(188, 204, 216, 0.72); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 0.2rem; + } + + .stream-kv-value { + font-size: 0.93rem; + color: #eef8f4; + word-break: break-word; + } + + .stream-warnings { + display: grid; + gap: 0.5rem; + } + + .stream-warning { + border-radius: 10px; + padding: 0.6rem 0.7rem; + background: rgba(248, 193, 66, 0.11); + border: 1px solid rgba(248, 193, 66, 0.28); + color: #ffe9b0; + font-size: 0.84rem; + } + + .stream-command-box { + border-radius: 10px; + background: rgba(6, 10, 14, 0.72); + border: 1px solid rgba(79, 107, 132, 0.32); + padding: 0.75rem; + max-height: 14rem; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.81rem; + } + + .stream-inline-actions { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + } + + .stream-create-profile-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.65rem; + } + + .stream-create-profile { + border-radius: 14px; + border: 1px solid rgba(78, 114, 145, 0.34); + background: rgba(15, 25, 38, 0.74); + padding: 0.85rem; + cursor: pointer; + transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease; + } + + .stream-create-profile.active { + border-color: rgba(86, 217, 146, 0.72); + background: rgba(24, 54, 46, 0.74); + transform: translateY(-1px); + } + + .stream-create-profile small { + display: block; + color: rgba(206, 224, 218, 0.7); + margin-top: 0.3rem; + } + + .stream-create-health { + border-radius: 12px; + border: 1px solid rgba(69, 97, 124, 0.4); + background: rgba(12, 21, 30, 0.88); + padding: 0.85rem; + } + + .stream-create-health-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.6rem; + } + + @media (max-width: 991.98px) { + .stream-workspace-shell, + .stream-create-profile-grid { + grid-template-columns: 1fr; + } + } + + @media (max-width: 575.98px) { + .stream-card-header { + flex-direction: column; + } + + .stream-status-grid, + .stream-create-health-grid { + grid-template-columns: 1fr; + } + + .stream-card-actions .btn { + flex: 1 1 auto; + } + } + @media (max-width: 430px) { .channel-header { padding: 0.34rem 0.38rem !important; @@ -2140,15 +2368,20 @@

  • -
  • -
  • +
  • + +
  • ` : ''; + const stopBtn = isOwner && streamId + ? `` + : ''; const streamBtn = streamId ? `` : `Stream metadata incomplete`; html += ` -
    -
    -
    -
    +
    +
    +
    +
    -
    ${safeTitle}
    -
    ${safeDesc || (streamKind === 'telemetry' ? 'Telemetry feed card' : (mediaKind === 'video' ? 'Video stream card' : 'Audio stream card'))}
    +
    ${safeTitle}
    +
    ${safeDesc || scenarioLabel}
    +
    + ${streamKind === 'telemetry' ? 'Telemetry' : (mediaKind === 'video' ? 'Video' : 'Audio')} + ${_escapeHtml(scenarioLabel)} + ${att.relay_allowed ? ' Relay ready' : ''} +
    - ${statusLabel} + ${statusLabel}
    -
    +
    ID: ${streamId || 'n/a'} -
    +
    ${streamBtn} ${goLiveBtn} + ${stopBtn} ${streamId ? `` : ''}
    - +
    `; }); @@ -8613,6 +8871,271 @@
    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: `![caption](file:FILE_ID)`. - 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')