Commit graph

306 commits

Author SHA1 Message Date
SergeantPanda
78aa8cf353 Enhancement: Change default new client behind seconds from 2 to 5 for stability. 2026-03-05 15:36:12 -06:00
SergeantPanda
9805732582 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/CodeBormen/1023 2026-03-05 11:16:20 -06:00
SergeantPanda
06f864ad6d fix(stream_generator): optimize resource and health checks with throttling 2026-03-04 17:37:42 -06:00
None
759688390b fix(dvr): fix series episode list, card layout, rule cleanup, and recording resilience
Backend:
- Add terminal status guard to stop() endpoint -prevents overwriting completed/interrupted/failed with "stopped" (returns 409 Conflict)
- Include currently-airing programs in series rule evaluation by filtering on end_time instead of start_time
- Clean up future recordings when deleting a series rule while preserving previously recorded episodes
- Add logo proxy negative cache to prevent repeated requests to unreachable hosts from blocking Daphne workers
- Add URL validation with HEAD-then-GET fallback and per-worker cache
- Add EPG time-slot matching (80% overlap threshold) to enrich manual recordings with program metadata
- Add multi-source poster resolution pipeline (EPG, VOD, TMDB, OMDb, TVMaze, iTunes) with URL validation at each stage
- Add editable recording metadata endpoint with user_edited flag to prevent EPG auto-overwrite
- Add refresh-artwork endpoint to re-run poster resolution on demand
- Batch-fetch PeriodicTask names in schedule_upcoming to avoid N+1

Frontend:
- Fix series modal showing only one episode — preserve _group_count from categorized prop when merging with store version so the episode list renders correctly
- Fix recording card button positioning — use flex column layout with marginTop auto so buttons align at the bottom regardless of content
- Add delete fallback in RecurringRuleModal for orphaned recordings from deleted rules
- Use end_time filter in upcoming episodes list to include currently-airing episodes
- Add inline metadata editing (title/description) in details modal
- Add refresh artwork button in details modal
- Replace hardcoded /logo.png fallbacks with imported default logo
- Add React.memo with custom comparator to RecordingCard

Tests:
- Add 21 EPG time-slot matching tests (overlap threshold, edge cases)
- Add 16 recording metadata tests (validation, artwork, logo cache)
- Add 17 URL validation tests (HEAD/GET fallback, caching, eviction)
2026-03-04 10:21:03 -06:00
None
e5206545dd fix(dvr): add stream reconnection resilience, fix comskip exit code handling
- Add reconnection loop to DVR streaming: on transient connectivity loss (ReadTimeout, ConnectionError, ChunkedEncodingError), the recording task retries the same TS proxy base up to 5 times with a 2-second delay, appending to the existing file. Counter resets on successful data receipt. TS container format tolerates brief discontinuities; MKV remux normalises timestamps.
- Extract _check_recording_cancelled() helper to consolidate repeated stop/delete DB checks across the streaming and reconnection paths.
- Fix comskip treating exit code 1 (no commercials detected) as a fatal error.
  Use check=False and handle return codes explicitly: 0 = commercials found,
  1 = no commercials (skipped), negative = signal, other = real error.
- Add human-readable reason messages for comskip 'skipped' notifications in
  WebSocket handler.
- Remove redundant conditional Channel import in stream_generator._cleanup() that shadowed the module-level import, causing UnboundLocalError when the conditional branch did not execute.
2026-03-04 10:21:02 -06:00
None
f28530a47b fix(dvr): add recording extend, fix cross-recording interference, artwork race, and keepalive gaps
File changes:

Backend

apps/channels/api_views.py — Added extend() action that uses queryset .update() to bypass signals, letting the running task pick up the new end_time via polling. Moved WS recording_stopped notification to synchronous (before HTTP response). Refactored destroy() to delete DB row first, send WS event immediately, defer cleanup to background thread.

apps/channels/signals.py — Added pre_save guard to skip task revocation when status is "recording". Added post_save reentrancy guard to skip artwork prefetch for internal field-only saves (custom_properties, task_id, end_time).

apps/channels/tasks.py — Added 2-second polling loop in streaming that checks for stop, deletion, and extended end_time. Added refresh_from_db() + merge before metadata save to prevent overwriting concurrent artwork prefetch. Replaced inline PeriodicTask cleanup with revoke_task()/_dvr_task_name(). Consolidated redundant DB queries in error path. Fixed TOCTOU os.path.exists + os.remove. Preserved "stopped" status in final-status logic instead of overwriting with "completed".

core/utils.py — Made send_websocket_update gevent-safe by detecting monkey-patching and offloading async_to_sync to gevent's native threadpool, fixing the event loop deadlock that caused cross-recording interference.

apps/proxy/ts_proxy/client_manager.py — Moved _trigger_stats_update to a background thread so remove_client() returns immediately.

apps/proxy/ts_proxy/stream_generator.py — Added Redis-based health check for non-owner workers in _should_send_keepalive(), so keepalives fire correctly even when stream_manager is None.

Frontend

frontend/src/WebSocket.jsx — Added 400ms debounced scheduleRecordingFetch() replacing all inline fetchRecordings() calls. Added recording_extended and recording_updated event handlers. recording_cancelled now does surgical removeRecording() by ID when available.

frontend/src/api.js — Added extendRecording(id, extraMinutes).

frontend/src/components/cards/RecordingCard.jsx — Added Extend menu (+15m/+30m/+1h) for in-progress recordings. Widened Comskip button to stopped/interrupted statuses.

frontend/src/components/forms/ProgramRecordingModal.jsx — Removed manual fetchRecordings() calls, relying on WS-driven debounced refresh.

frontend/src/components/forms/RecordingDetailsModal.jsx — Added stopped to playable statuses. Widened Comskip button. Removed manual fetchRecordings().

frontend/src/components/forms/RecurringRuleModal.jsx — Removed all manual fetchRecordings() calls, relying on WS events.

frontend/src/pages/Guide.jsx — Removed isLoading subscription that caused loading flash on debounced refresh. Removed manual fetchRecordings() after saving a series rule.

frontend/src/store/channels.jsx — Stripped isLoading/error state from fetchRecordings to prevent full-page loading indicators. Added no-op guards in removeRecording.

frontend/src/utils/cards/RecordingCardUtils.js — Added extendRecordingById wrapper.

Tests

apps/channels/tests/test_recording_extend.py (new) — 14 tests: extend endpoint validation, stacked extensions, signal bypass, pre_save guard behavior.

apps/channels/tests/test_recording_stop_cancel.py (new) — 18 tests: stop endpoint, DVR client teardown, cancel was_in_progress flag, run_recording race guards, signal reentrancy guards.

apps/channels/tests/test_ts_proxy_keepalive.py (new) — 21 tests: keepalive owner/non-owner logic, stats update error handling, client removal non-blocking, timing invariants.

apps/channels/tests/test_recording_scheduling.py — Updated mock assertion to match new _stop_dvr_clients call signature.

Summary:

Adds an Extend action for in-progress recordings that bypasses Django signals and lets the running task dynamically pick up the new end_time via its 2-second DB polling loop. Fixes cross-recording interference caused by send_websocket_update deadlocking the gevent event loop. Fixes an artwork race condition where run_recording overwrote concurrent prefetch data. Adds Redis-based keepalive health checks for non-owner workers to prevent DVR read timeouts during source transitions. Replaces manual fetchRecordings() calls throughout the frontend with a debounced WS-driven refresh. 53 new tests.
2026-03-04 10:21:02 -06:00
SergeantPanda
fc75a68195 Enhancement: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to 0 starts clients at live with no buffer. Defaults to 2 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings → Proxy as "New Client Buffer (seconds)". 2026-03-03 18:12:32 -06:00
SergeantPanda
d70450b7fc Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/CodeBormen/949 2026-03-03 16:16:25 -06:00
SergeantPanda
e78c24aad7 Enhance Redis chunk management in StreamBuffer and StreamGenerator
- Register Lua scripts for optimized chunk retrieval in StreamBuffer.
- Implement atomic Lua binary search to find the oldest available chunk in Redis.
- Update StreamGenerator to handle expired chunks more efficiently, reducing TOCTOU race conditions.
2026-03-03 15:13:46 -06:00
None
7653859a9a Reset connection_allocated flag after redirect stream release
The redirect path in stream_ts() released the connection slot via release_stream() but did not reset the connection_allocated flag. Could have could caused a redundant no-op call in the exception handler.
2026-03-03 14:22:45 -06:00
None
c96603f923 Merge remote-tracking branch 'upstream/dev' into fix/947-Connection-capacity-leak-failed-stream-initialization-permanently-consumes-connection-slot 2026-03-03 14:02:52 -06:00
SergeantPanda
6d7130d71e Bug Fix: fix get_instance deadlock and non-atomic ownership acquisition
If ProxyServer() raises during singleton construction, _instance was left
as _INITIALIZING permanently, causing all subsequent get_instance() callers
to spin in an infinite gevent.sleep() loop. Wrap construction in try/except
and reset _instance to None on failure so callers can retry.

Also replace the non-atomic setnx() + expire() pair in try_acquire_ownership()
with a single atomic SET NX EX call, consistent with the approach already used
in extend_ownership() and eliminating the race window where a crash between the
two calls could leave a key with no TTL (permanent ownership lock).
2026-03-03 11:06:52 -06:00
Claude Code
7fed49a334 Address review feedback from CodeBormen
- Use atomic SET NX EX instead of separate SETNX + EXPIRE to prevent
  zombie locks if the process crashes between the two calls
- Replace time.sleep() with gevent.sleep() in get_instance() spin-wait
  to avoid blocking the greenlet hub
- Defer cleanup when ownership is lost but clients are still connected,
  giving the new owner time to establish its stream before we tear down

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:23:45 +00:00
None
0e7eb916c2 Merge remote-tracking branch 'upstream/dev' into fix/947-Connection-capacity-leak-failed-stream-initialization-permanently-consumes-connection-slot 2026-02-27 20:59:30 -06:00
PFalko
8bd38ad71c Fix stream ownership bugs causing streams to die after 30-200s
Three interrelated bugs cause TS proxy streams to terminate prematurely
in multi-worker uWSGI deployments:

1. Double ProxyServer instantiation: ProxyConfig.ready() in apps/proxy/apps.py
   calls TSProxyServer() directly, bypassing get_instance(). The subsequent
   TSProxyConfig.ready() call to get_instance() creates a second instance.
   Each instance starts its own cleanup thread, but only one holds channel
   data — the orphaned cleanup thread cannot extend ownership.

2. Redis flushdb() on every client init: RedisClient.get_client() in
   core/utils.py calls flushdb() whenever a new connection is created.
   Celery autoscale workers spawning mid-stream nuke all Redis keys
   including ownership, client records, and channel metadata.

3. No recovery from expired ownership: get_channel_owner() has a TOCTOU
   bug (two separate GET calls in a lambda). extend_ownership() silently
   fails when keys expire. Non-owner cleanup unconditionally kills streams
   even when the worker holds the stream_manager.

Fixes:
- Use TSProxyServer.get_instance() in ProxyConfig.ready()
- Remove flushdb() from Redis client initialization
- Use sentinel pattern for gevent-safe singleton (threading.Lock does not
  work with gevent greenlets)
- Single GET in get_channel_owner() to avoid TOCTOU race
- Re-acquire expired ownership keys in extend_ownership()
- Attempt re-acquisition before cleanup in non-owner path

Relates to #992, #980
2026-02-27 16:40:28 +01:00
None
c69718b559 Fix: Make release_stream() idempotent to prevent double-DECR on rapid channel switching
Multiple code paths call release_stream() during channel shutdown (stream generator cleanup, _clean_redis_keys, ChannelService.stop_channel), causing
the profile_connections counter to be decremented multiple times for the same stream. The metadata hash fallback persisted longer than the primary keys, allowing subsequent calls to find stale stream_id/m3u_profile and DECR again.

Changes:
- Clear STREAM_ID and M3U_PROFILE metadata fields after decrementing in both the primary and fallback paths, making release_stream() safe to call multiple times
- Add metadata fallback when stream_profile:{id} key is missing but channel_stream:{id} exists, preventing a counter leak in that edge case
- Replace redundant get_stream() call in views.py with direct Redis reads to avoid side-effect INCR from a method that can allocate connection slots
2026-02-17 21:18:20 -06:00
None
58ccf2d6e1 Fix: VOD proxy connection counter leaks on client disconnect and seeking
Fix multiple VOD proxy connection counter leak paths that caused
profile_connections to remain permanently elevated after client
disconnects, leaving profiles locked at capacity.

Changes:
- Fix get_stream() race condition: save state under lock with fresh active_streams to prevent overwriting concurrent decrements during scrubbing/seeking (root cause of counter stuck after timeshift)
- Add early active_streams increment in session reuse path to prevent cleanup race between GeneratorExit and new request
- Add rollback on get_stream() returning None (range not satisfiable)
- Add profile_id fallback to closure variable when Redis state is deleted before generator cleanup runs
- Change increment/decrement return types and add warning logs for lock acquisition and state failures
- Clean up debug traceback logging from increment/decrement methods
2026-02-17 20:24:00 -06:00
None
4842bb87fa Fix: VOD proxy connection counter leak on client disconnect
Three fixes:
- TOCTOU: Replace GET-check-INCR with atomic INCR-first-then-check pattern in both connection managers to prevent concurrent requests exceeding max_streams
- Immediate DECR: Decrement profile counter directly in stream generator exit paths (normal completion, client disconnect, error) instead of deferring to daemon thread cleanup which may never execute
- Rollback: Decrement profile counter on create_connection() failure so the reserved slot is released

Fixes #962
2026-02-13 19:37:06 -06:00
None
51b5824737 Fix cleanup paths for stream preview
Fix exception handling in _clean_redis_keys() and stop_channel()
   where `except Channel.DoesNotExist` didn't catch ValidationError from invalid UUID format, bypassing the Stream fallback path.
2026-02-12 00:09:29 -06:00
None
20ce45f7ad fix(ts-proxy): Add Stream fallback in cleanup path to prevent counter leak on stream preview
When previewing a stream directly (via stream hash), the cleanup in stream_generator.py tried Channel.objects.get(uuid=stream_hash) which failed with ValidationError since the hash is not a UUID. This
prevented release_stream() from running, permanently leaking the
connection counter. With max_streams=1, a single preview permanently blocked all subsequent streams.

Added Stream.objects.get(stream_hash=...) fallback so the cleanup
path works for both Channel (UUID) and Stream (hash) identifiers.
Also added Stream import to stream_generator.py.
2026-02-12 00:02:40 -06:00
None
8bb8a0fca9 fix(ts-proxy): Remove redundant local import causing UnboundLocalError
The local `from apps.channels.models import Channel` inside a
conditional block in _handle_client_disconnect() shadowed the top-level import. When the condition was False, Python's function-level scoping caused line 462's Channel.objects.get() to raise UnboundLocalError. Channel is already imported at module level (line 11), making the local import unnecessary.

Discovered when testing proxy changes
2026-02-11 20:52:30 -06:00
None
6360271e28 Fix unprotected _clean_redis_keys() call in check_if_channel_exists
Wrap _clean_redis_keys() call in check_if_channel_exists() with
try/except to prevent exception propagation when Channel/Stream
lookup or release_stream() encounters DB or Redis errors.
2026-02-11 20:09:17 -06:00
None
fe17d0d585 Check release_stream() boolean return at all call sites
All 12 call sites for Channel.release_stream() and Stream.release_stream() now check the boolean return value and log warnings when release fails.

Files updated:
- server.py: 4 call sites (zombie cleanup + _clean_redis_keys)
- channel_service.py: 2 call sites (stop_channel)
- stream_generator.py: 1 call site (last client disconnect)
- views.py: 4 call sites (failed init, redirect, init failure, exception)
- url_utils.py: 1 call site (URL generation error)

Also fixed bare except clause in _clean_redis_keys and uncaught
Stream.DoesNotExist in channel_service.stop_channel.

Based on PR #838 by patchy8736
2026-02-11 19:49:11 -06:00
None
cf96287c90 Add guard to remove_connection()
- multi_worker_connection_manager.py: Add missing count > 0 guard to multi-worker remove_connection()

This method doesn't appear to be called anywhere but added the guard in case it is put back into service
2026-02-10 19:06:51 -06:00
None
9e8f227f49 Fix: VOD proxy double-DECR of profile connection counter during cleanup
- multi_worker_connection_manager.py: Error handler at line 1042: removed connection_manager=self from cleanup() call since the DECR was already done at line 1036. Prevents double-DECR.

- connection_manager.py: Added connection_key_exists check before calling remove_connection(). The fallback DECR from session data now only fires when the connection tracking key has already expired. Prevents double-DECR.
2026-02-10 18:58:17 -06:00
None
16309cd9b4 Remove if logic from scheduled shutdown
- stream_generator.py: _schedule_channel_shutdown_if_needed() already guards itself, the if logic created conflict between release_stream() and stop_channel(), so if the stream was already released the shutdown would not be scheduled
2026-02-10 18:15:48 -06:00
Matt Grutza
681ffe1779
Merge branch 'Dispatcharr:main' into fix/947-Connection-capacity-leak-failed-stream-initialization-permanently-consumes-connection-slot 2026-02-10 17:51:25 -06:00
None
49b7b9e21b Fix: Connection capacity leak - failed stream initialization permanently consumes connection slot
Fixed four leak paths in the TS proxy streaming initialization:

1. url_utils.py - generate_stream_url(): Wrapped post-get_stream() code in try/except that calls release_stream() on any failure

2. views.py - stream_ts() retry loop: Added release_stream() before returning 503 to clean up the error-checking get_stream() call at line 181 which could INCR without a matching release when the retry loop times out

3. views.py - stream_ts() init failure: Added guarded release_stream() when initialize_channel() returns False, using a connection_allocated flag to prevent incorrect decrements when joining an existing channel

4. views.py - stream_ts() outer except: Added guarded release_stream() as a safety net for unexpected exceptions, only releasing when the flag confirms the slot was allocated and the channel hasn't been initialized yet

The connection_allocated flag prevents double-decrements by tracking whether request flow actually INCR'd the counter (fresh initialization) vs
returned cached results (joining an existing channel on another worker)
2026-02-05 23:12:30 -06:00
None
e217960500 feat: Add Redis authentication support for modular deployment
Add support for authentication when connecting to external Redis instances in modular deployment mode. This enables secure Redis deployments using either password-only authentication (Redis <6) or
username + password authentication (Redis 6+ ACL).

Changes:
- Add REDIS_PASSWORD and REDIS_USER environment variables
- Implement URL encoding for special characters in passwords
- Update all Redis connection points to support auth
- Add comprehensive documentation and examples to docker-compose.yml
- Maintains full backward compatibility (empty defaults = no auth)

All authentication mechanisms have been fully tested including pasword-only authentication, Redis 6+ ACL authentication with username + password, volume-mounted configuration files, and special character handling.

Existing deployments are not effected, authentication support is entirely opt-in using docker-compose.

Also, acknowledging the fact that the docker-compose file for modular deployments has been getting out of hand with auth support, the docker-compose.yml file was re-formatted for better visibility in configuration. This seemed like a better way to go than mandating a .env file.
2026-02-03 21:42:06 -06:00
SergeantPanda
9612a67412 Change: VOD upstream read timeout reduced from 30 seconds to 10 seconds to minimize lock hold time when clients disconnect during connection phase
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
2026-01-04 15:21:22 -06:00
SergeantPanda
4e65ffd113 Bug fix: Fixed VOD profile connection count not being decremented when stream connection fails (timeout, 404, etc.), preventing profiles from reaching capacity limits and rejecting valid stream requests 2026-01-04 15:00:08 -06:00
Kevin Napoli
1ef5a9ca13
Fix: Continue GET request if HEAD fails with the peer closing the connection without returning a response 2025-12-26 15:27:51 +01:00
SergeantPanda
31b9868bfd Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/sethwv/757 2025-12-24 16:04:04 -06:00
SergeantPanda
daa919c764 Refactor logging messages in StreamManager for clarity and consistency. Also removed redundant parsing. 2025-12-23 15:52:56 -06:00
SergeantPanda
ff7298a93e Enhance StreamManager for efficient log parsing and update VLC stream profile naming 2025-12-23 15:07:25 -06:00
SergeantPanda
904500906c Bug Fix: Update stream validation to return original URL instead of redirected URL when using redirect profile. 2025-12-23 09:51:02 -06:00
Seth Van Niekerk
aa5db6c3f4
Squash: Log Parsing Refactor & Enhancing 2025-12-22 15:14:46 -05:00
SergeantPanda
de31826137 refactor: externalize Redis and Celery configuration via environment variables
Replace hardcoded localhost:6379 values throughout codebase with environment-based configuration. Add REDIS_PORT support and allow REDIS_URL override for external Redis services. Configure Celery broker/result backend to use Redis settings with environment variable overrides.

Closes #762
2025-12-18 16:54:59 -06:00
SergeantPanda
2558ea0b0b Enhancement: Add VOD client stop functionality to Stats page 2025-12-17 16:54:10 -06:00
Seth Van Niekerk
bc72b2d4a3
Add VLC and streamlink codec parsing support 2025-12-15 20:09:54 -05:00
Seth Van Niekerk
88c10e85c3
Add VLC TS demux output detection for codec parsing 2025-12-15 20:09:54 -05:00
SergeantPanda
13ad62d3e1 Bug Fix: Fix bug where previewing a stream would always select the default m3u profile regardless of utilization. 2025-11-25 13:07:49 -06:00
SergeantPanda
89a23164ff Enhancement: Add system event logging and viewer with M3U/EPG endpoint caching
System Event Logging:
- Add SystemEvent model with 15 event types tracking channel operations, client connections, M3U/EPG activities, and buffering events
- Log detailed metrics for M3U/EPG refresh operations (streams/programs created/updated/deleted)
- Track M3U/EPG downloads with client information (IP address, user agent, profile, channel count)
- Record channel lifecycle events (start, stop, reconnect) with stream and client details
- Monitor client connections/disconnections and buffering events with stream metadata

Event Viewer UI:
- Add SystemEvents component with real-time updates via WebSocket
- Implement pagination, filtering by event type, and configurable auto-refresh
- Display events with color-coded badges and type-specific icons
- Integrate event viewer into Stats page with modal display
- Add event management settings (retention period, refresh rate)

M3U/EPG Endpoint Optimizations:
- Implement content caching with 5-minute TTL to reduce duplicate processing
- Add client-based event deduplication (2-second window) using IP and user agent hashing
- Support HEAD requests for efficient preflight checks
- Cache streamed EPG responses while maintaining streaming behavior for first request
2025-11-20 17:41:06 -06:00
SergeantPanda
b6c3234e96 Enhancement: Improve zombie channel handling in ProxyServer by checking client connections and cleaning up orphaned metadata, ensuring better resource management and stability. 2025-11-18 10:02:35 -06:00
SergeantPanda
6bd5958c3c Enhancement: Improve channel shutdown logic in ProxyServer to handle connection timeouts and grace periods more effectively, ensuring proper channel management based on client connections. 2025-11-15 14:22:26 -06:00
SergeantPanda
2514528337 Enhancement: Update channel state handling in ProxyServer and views to include 'STOPPING' state, ensuring proper cleanup and preventing reinitialization during shutdown. 2025-11-14 19:57:59 -06:00
SergeantPanda
6e79b37a66 When stream_type is UDP do not add user_agent to FFmpeg command. 2025-11-13 14:04:46 -06:00
SergeantPanda
4720e045a3 Implement manual redirect for non-HTTP protocols (RTSP/RTP/UDP) in stream URL handling 2025-11-12 17:31:27 -06:00
SergeantPanda
a3c16d48ec Skip HTTP validation for non-HTTP protocols (UDP/RTP/RTSP) in stream URL validation 2025-11-12 16:17:06 -06:00
SergeantPanda
b9e819e343 Enhancement: Adds support for UDP streams. Closes #617 2025-11-11 18:30:59 -06:00