The get_series endpoint was omitting tmdb_id and imdb_id fields that
are already present on the Series model and already included in the
get_vod_streams (movies) response.
Xtream clients such as Chillio use these IDs to fetch proper metadata
from TMDB, including sanitized series titles and poster images. Without
them, clients fall back to the raw provider name (which may include
provider prefixes like ┃NL┃) and the proxied cover URL instead of
TMDB's poster.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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
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
- Add proper datetime validation in API with 400 error responses
- Fix test timestamp format (was creating invalid +00:00Z format)
- Time filters now properly validate and return errors on invalid input
- All 26 tests passing with correct filtering behavior
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
Add cron expression support for M3U and EPG refreshes with interactive
builder modal and quick reference examples. Refactor backup scheduling
to use shared ScheduleInput component for consistency.
- New CronBuilder modal with preset buttons and custom field editors
- Info popover with common cron expression examples
- Shared ScheduleInput component for interval/cron toggle
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.
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.
Stream.get_stream() was incorrectly removed as dead code in commit
33f68a98. It is actually called get_stream_object()
in views.py when a stream hash (not channel UUID) is used, such as
during stream preview. The variable is named channel but holds a
Stream instance, which masked the dependency during analysis.
Restored with the INCR-first-then-check pattern applied, fixing the
same TOCTOU vulnerability the original method had.
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
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.
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
Stream.release_stream() now returns True on success and False when no
profile info could be found in stream_profile:{stream_id}. This allows
callers to detect and handle failed releases.
Enhanced logging to include stream ID for better traceability.
Based on PR #838 by patchy8736
Channel.release_stream() now returns True on success and False when no
stream/profile info could be found for cleanup. This allows callers to
detect and handle failed releases.
When the primary Redis keys (channel_stream/stream_profile) are already cleaned up by the proxy, the method falls back to the channel metadata hash (ts_proxy:channel:{uuid}:metadata) to recover stream_id and profile_id for proper connection counter cleanup.
Uses str(self.uuid) for metadata key lookup - fixes a bug in PR #838
which used self.id (integer PK), but metadata is keyed by UUID.
Enhanced logging now includes channel UUID for better traceability.
Based on PR #838 by patchy8736
Remove the Stream.get_stream() method which had the same TOCTOU race condition as Channel.get_stream()
All stream acquisition goes through
Channel.get_stream(), making this dead code. Removing it eliminates
a potential source of connection capacity leaks
Based on PR #838 by patchy8736
Add _check_and_reserve_profile_slot() method that uses an INCR-first-then-check pattern to atomically reserve connection slots. This eliminates the TOCTOU race condition where separate GET > check > INCR operations allowed concurrent requests to both pass the capacity check, exceeding max_streams.
For profiles with max_streams=0 (unlimited), INCR is skipped entirely. If over capacity, the increment is rolled back with DECR.
Based on PR #838 by patchy8736, adapted from Lua script to native Redis commands for consistency with project style and easier debugging.
- 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
- 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.
- 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
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)
- Fix credential extraction in get_transformed_credentials() using negative
indices anchored to the known tail structure instead of hardcoded indices
that break when server URLs contain sub-paths
- Fix EPG URL construction in M3U form to normalize server URL to origin
before appending xmltv.php endpoint
XC accounts with sub-paths in their server URL (e.g., http://server.com/portal/a/) caused two failures: profile refresh extracted wrong credentials from the URL path due to hardcoded array indices, and EPG auto-creation appended xmltv.php under the sub-path instead of at the domain root. Both fixes normalize away the sub-path using the same approach the backend's XCClient already uses for API calls.