mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 17:16:26 +00:00
Add end-to-end catch-up support for XC clients and native apps: provider proxy with failover and per-viewer session pooling, REST session minting for tokenless playback URLs, catch-up admin stats, combined connection stats, and Stats UI with dedicated cards plus websocket updates. Includes Redis namespace consolidation under timeshift:* (dropping legacy timeshift_ id prefixes), dedicated catch-up stop by session_id, and cleaner channel/client metadata split for stats keys. Closes #133
395 lines
17 KiB
Python
395 lines
17 KiB
Python
import logging
|
|
import re
|
|
from core.utils import RedisClient
|
|
from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, get_vod_client_stop_key
|
|
from apps.timeshift.redis_keys import (
|
|
TimeshiftRedisKeys,
|
|
parse_stats_channel_id,
|
|
)
|
|
from core.models import CoreSettings
|
|
from apps.proxy.live_proxy.services.channel_service import ChannelService
|
|
|
|
logger = logging.getLogger("proxy")
|
|
|
|
_STOP_REASON_LIMIT = "limit"
|
|
_STOP_REASON_ADMIN = "admin"
|
|
|
|
|
|
def _timeshift_stop_channel_id(redis_client, stats_channel_id, client_id, fallback=None):
|
|
"""Return the programme virtual_channel_id used for generator stop keys."""
|
|
client_key = TimeshiftRedisKeys.client_metadata(stats_channel_id, client_id)
|
|
programme_vid = redis_client.hget(client_key, "programme_vid")
|
|
if programme_vid:
|
|
if isinstance(programme_vid, bytes):
|
|
programme_vid = programme_vid.decode()
|
|
return programme_vid
|
|
return fallback if fallback is not None else stats_channel_id
|
|
|
|
|
|
def _timeshift_generation_channel_ids(redis_client, client_id):
|
|
"""Return virtual channel ids with active generation counters for a client."""
|
|
if redis_client is None or not client_id:
|
|
return []
|
|
prefix = "timeshift:stream_gen:"
|
|
suffix = f":{client_id}"
|
|
try:
|
|
keys = redis_client.scan_iter(
|
|
match=f"{prefix}*{suffix}", count=100,
|
|
)
|
|
channel_ids = []
|
|
for key in keys:
|
|
if isinstance(key, bytes):
|
|
key = key.decode()
|
|
key = str(key)
|
|
if not key.startswith(prefix) or not key.endswith(suffix):
|
|
continue
|
|
channel_id = key[len(prefix):-len(suffix)]
|
|
if channel_id:
|
|
channel_ids.append(channel_id)
|
|
return channel_ids
|
|
except Exception as exc:
|
|
logger.debug(
|
|
"Timeshift generation stop-key scan failed for %s: %s",
|
|
client_id, exc,
|
|
)
|
|
return []
|
|
|
|
|
|
def _timeshift_stop_channel_ids(redis_client, stats_channel_id, client_id, fallback=None):
|
|
"""Return all Redis stop-key channel ids that may be observed by a stream."""
|
|
candidates = [
|
|
_timeshift_stop_channel_id(
|
|
redis_client, stats_channel_id, client_id, fallback=fallback,
|
|
)
|
|
]
|
|
candidates.extend(_timeshift_generation_channel_ids(redis_client, client_id))
|
|
|
|
seen = set()
|
|
ordered = []
|
|
for channel_id in candidates:
|
|
if not channel_id or channel_id in seen:
|
|
continue
|
|
seen.add(channel_id)
|
|
ordered.append(channel_id)
|
|
return ordered
|
|
|
|
|
|
def _set_timeshift_stop_keys(redis_client, stats_channel_id, client_id, reason, fallback=None):
|
|
"""Signal a timeshift stream to stop across workers."""
|
|
stop_channel_ids = _timeshift_stop_channel_ids(
|
|
redis_client, stats_channel_id, client_id, fallback=fallback,
|
|
)
|
|
for stop_channel_id in stop_channel_ids:
|
|
stop_key = TimeshiftRedisKeys.client_stop(stop_channel_id, client_id)
|
|
redis_client.setex(stop_key, 60, reason)
|
|
return stop_channel_ids
|
|
|
|
|
|
def stop_timeshift_client(redis_client, stats_channel_id, client_id):
|
|
"""Stop one catch-up viewer (admin Stats UI and ``POST /proxy/catchup/stop_client/``)."""
|
|
if redis_client is None:
|
|
return {"status": "error", "message": "Redis unavailable"}
|
|
if not stats_channel_id or not client_id:
|
|
return {"status": "error", "message": "Missing channel or client id"}
|
|
|
|
from apps.timeshift.views import (
|
|
_cancel_stats_disconnect_grace,
|
|
_cleanup_all_stream_generations,
|
|
_close_active_upstream,
|
|
_finalize_playback_session_auth,
|
|
_trigger_timeshift_stats_update,
|
|
_unregister_stats_client,
|
|
)
|
|
|
|
try:
|
|
stop_channel_ids = _set_timeshift_stop_keys(
|
|
redis_client, stats_channel_id, client_id, _STOP_REASON_ADMIN,
|
|
)
|
|
for stop_channel_id in stop_channel_ids:
|
|
_close_active_upstream(stop_channel_id, client_id)
|
|
_cancel_stats_disconnect_grace(redis_client, stats_channel_id, client_id)
|
|
_unregister_stats_client(redis_client, stats_channel_id, client_id)
|
|
_cleanup_all_stream_generations(redis_client, client_id)
|
|
_finalize_playback_session_auth(redis_client, client_id)
|
|
try:
|
|
from apps.timeshift.views import _superseded_pool_key
|
|
redis_client.delete(_superseded_pool_key(client_id))
|
|
except Exception:
|
|
pass
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Timeshift admin stop failed for %s on %s: %s",
|
|
client_id, stats_channel_id, exc,
|
|
)
|
|
return {"status": "error", "message": str(exc)}
|
|
|
|
_trigger_timeshift_stats_update(redis_client)
|
|
|
|
logger.info(
|
|
"Timeshift admin stop: client=%s stats_channel=%s stop_channels=%s",
|
|
client_id, stats_channel_id, stop_channel_ids,
|
|
)
|
|
return {
|
|
"status": "success",
|
|
"message": "Timeshift client stop signal sent",
|
|
"channel_id": stats_channel_id,
|
|
"client_id": client_id,
|
|
"stop_channel_id": stop_channel_ids[0] if stop_channel_ids else stats_channel_id,
|
|
"stop_channel_ids": stop_channel_ids,
|
|
"stop_key_set": True,
|
|
"locally_processed": False,
|
|
}
|
|
|
|
|
|
def attempt_stream_termination(user_id, requesting_client_id, active_connections):
|
|
try:
|
|
logger.info("[stream limits]" f"[{requesting_client_id}] User {user_id} has {len(active_connections)} active connections, checking termination candidates")
|
|
|
|
user_limit_settings = CoreSettings.get_user_limits_settings()
|
|
terminate_oldest = user_limit_settings.get("terminate_oldest", True)
|
|
prioritize_single = user_limit_settings.get("prioritize_single_client_channels", True)
|
|
ignore_same_channel = user_limit_settings.get("ignore_same_channel_connections", False)
|
|
|
|
channel_counts = {}
|
|
for connection in active_connections:
|
|
media_id = connection['media_id']
|
|
channel_counts[media_id] = channel_counts.get(media_id, 0) + 1
|
|
|
|
def prioritize(connection):
|
|
is_multi = channel_counts[connection['media_id']] > 1
|
|
|
|
# if we're ignoring same-channel connections, put them at the end
|
|
same_ch_key = 1 if (ignore_same_channel and is_multi) else 0
|
|
|
|
# key for prioritizing single-client channels
|
|
single_key = 0 if (prioritize_single and not is_multi) else 1
|
|
|
|
# sort by age setting
|
|
time_key = connection['connected_at'] if terminate_oldest else -connection['connected_at']
|
|
|
|
return (same_ch_key, single_key, time_key)
|
|
|
|
termination_candidates = sorted(active_connections, key=prioritize)
|
|
|
|
if not termination_candidates:
|
|
logger.warning("[stream limits]" f"[{requesting_client_id}] No termination candidates found for user {user_id}")
|
|
return False
|
|
|
|
target = termination_candidates[0]
|
|
logger.info("[stream limits]"
|
|
f"[{requesting_client_id}] Terminating client {target['client_id']} "
|
|
f"on media {target['media_id']} (connected_at={target['connected_at']})"
|
|
)
|
|
|
|
# When counting by unique channel, stop all connections on that channel.
|
|
targets = (
|
|
[c for c in active_connections if c['media_id'] == target['media_id']]
|
|
if ignore_same_channel
|
|
else [target]
|
|
)
|
|
|
|
for t in targets:
|
|
if t['type'] == 'live':
|
|
result = ChannelService.stop_client(t['media_id'], t['client_id'])
|
|
if result.get("status") == "error":
|
|
logger.warning(f"[stream limits][{requesting_client_id}] Failed to stop client {t['client_id']} on channel {t['media_id']}")
|
|
elif t['type'] == 'timeshift':
|
|
redis_client = RedisClient.get_client()
|
|
if not redis_client:
|
|
# Deny the new stream if we cannot stop the old one.
|
|
return False
|
|
stop_channel_ids = _set_timeshift_stop_keys(
|
|
redis_client, t['media_id'], t['client_id'], _STOP_REASON_LIMIT,
|
|
)
|
|
from apps.timeshift.views import _close_active_upstream
|
|
for stop_channel_id in stop_channel_ids:
|
|
_close_active_upstream(stop_channel_id, t['client_id'])
|
|
logger.info(
|
|
f"[stream limits][{requesting_client_id}] Set stop key for "
|
|
f"timeshift client {t['client_id']} on {stop_channel_ids}",
|
|
)
|
|
else:
|
|
connection_manager = MultiWorkerVODConnectionManager.get_instance()
|
|
redis_client = connection_manager.redis_client
|
|
|
|
if not redis_client:
|
|
return False
|
|
|
|
connection_key = f"vod_persistent_connection:{t['client_id']}"
|
|
connection_data = redis_client.hgetall(connection_key)
|
|
if not connection_data:
|
|
logger.warning(f"VOD connection not found: {t['client_id']}")
|
|
continue
|
|
|
|
stop_key = get_vod_client_stop_key(t['client_id'])
|
|
redis_client.setex(stop_key, 60, "true") # 60 second TTL
|
|
|
|
return True
|
|
except Exception as e:
|
|
logger.error("[stream limits]" f"[{requesting_client_id}] Error during stream termination for user {user_id}: {e}")
|
|
return False
|
|
|
|
def get_user_active_connections(user_id):
|
|
"""Return active stream connections for a single user.
|
|
|
|
Pass `user_id=None` to return all active connections across the system.
|
|
"""
|
|
redis_client = RedisClient.get_client()
|
|
connections = []
|
|
|
|
try:
|
|
# Grab live and timeshift streams (same key layout, separate namespaces)
|
|
for pattern, conn_type in (
|
|
("live:channel:*:clients:*", "live"),
|
|
("timeshift:channel:*:clients:*", "timeshift"),
|
|
):
|
|
for key in redis_client.scan_iter(match=pattern, count=1000):
|
|
parts = key.split(':')
|
|
if len(parts) >= 5:
|
|
channel_id = parts[2]
|
|
client_id = parts[4]
|
|
|
|
client_user_id, connected_at = redis_client.hmget(key, 'user_id', 'connected_at')
|
|
|
|
logger.debug(f"[stream limits] user_id = {user_id}")
|
|
logger.debug(f"[stream limits] channel_id = {channel_id}")
|
|
logger.debug(f"[stream limits] client_id = {client_id}")
|
|
|
|
if user_id is None or (client_user_id and int(client_user_id) == user_id):
|
|
try:
|
|
logger.debug(f"[stream limits] Found {conn_type.upper()} connection for user {user_id} on channel {channel_id} with client ID {client_id}")
|
|
connected_at = float(connected_at) if connected_at else 0
|
|
connections.append({
|
|
'media_id': channel_id,
|
|
'client_id': client_id,
|
|
'connected_at': connected_at,
|
|
'type': conn_type,
|
|
})
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Grab VOD
|
|
for key in redis_client.scan_iter(match="vod_persistent_connection:*", count=1000):
|
|
parts = key.split(':')
|
|
if len(parts) >= 2:
|
|
client_id = parts[1]
|
|
|
|
client_user_id, connected_at, content_uuid = redis_client.hmget(
|
|
key, 'user_id', 'created_at', 'content_uuid'
|
|
)
|
|
|
|
logger.debug(f"[stream limits] user_id = {user_id}")
|
|
logger.debug(f"[stream limits] client_id = {client_id}")
|
|
|
|
if user_id is None or (client_user_id and int(client_user_id) == user_id):
|
|
try:
|
|
logger.debug(f"[stream limits] Found VOD connection for user {user_id} on content {content_uuid} with client ID {client_id}")
|
|
connected_at = float(connected_at) if connected_at else 0
|
|
connections.append({
|
|
'media_id': content_uuid or client_id,
|
|
'client_id': client_id,
|
|
'connected_at': connected_at,
|
|
'type': 'vod',
|
|
})
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
return connections
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Error getting active channel details for user {user_id}: {e}")
|
|
return []
|
|
|
|
|
|
def check_user_stream_limits(user, client_id, media_id=None):
|
|
# Check user stream limits
|
|
if user and user.stream_limit > 0:
|
|
logger.debug("[stream limits]" f"[{client_id}] User {user.username} (ID: {user.id}) is requesting a stream (stream_limit: {user.stream_limit})")
|
|
user_limit_settings = CoreSettings.get_user_limits_settings()
|
|
ignore_same_channel = user_limit_settings.get("ignore_same_channel_connections", False)
|
|
|
|
active_connections = get_user_active_connections(user.id)
|
|
unique_channel_count = set([conn['media_id'] for conn in active_connections])
|
|
user_stream_count = len(unique_channel_count) if ignore_same_channel else len(active_connections)
|
|
|
|
logger.debug(f"[stream limits]" f"[{client_id}] User {user.username} currently has {len(active_connections)} active connections across {len(unique_channel_count)} unique channels (counting method: {'unique channels' if ignore_same_channel else 'total connections'})")
|
|
|
|
# If ignore_same_channel is enabled and this request is for a live channel the user
|
|
# is already watching, allow it through without counting against the limit.
|
|
# VOD is excluded: connections aren't shared so multiple VOD connections to the
|
|
# same content would mean multiple upstream connections.
|
|
live_channel_ids = {str(conn['media_id']) for conn in active_connections if conn['type'] == 'live'}
|
|
if ignore_same_channel and media_id and str(media_id) in live_channel_ids:
|
|
logger.debug(f"[stream limits][{client_id}] Same-channel reconnect for {media_id} allowed (ignore_same_channel=True)")
|
|
return True
|
|
|
|
# Timeshift sibling range/probe requests share one provider slot per
|
|
# session_id. Each distinct client/session still consumes its own slot.
|
|
if media_id and client_id:
|
|
media_id_str = str(media_id)
|
|
req_channel = None
|
|
channel_match = re.match(r"^(\d+)_", media_id_str)
|
|
if channel_match:
|
|
req_channel = channel_match.group(1)
|
|
for conn in active_connections:
|
|
if conn.get('type') != 'timeshift':
|
|
continue
|
|
if conn.get('client_id') != client_id:
|
|
continue
|
|
conn_media_id = str(conn.get('media_id') or '')
|
|
if conn_media_id == media_id_str or conn_media_id.startswith(f"{media_id_str}_"):
|
|
logger.debug(
|
|
f"[stream limits][{client_id}] Same timeshift session probe for {media_id} allowed"
|
|
)
|
|
return True
|
|
if req_channel:
|
|
parsed_conn = parse_stats_channel_id(conn_media_id)
|
|
if parsed_conn:
|
|
conn_channel = str(parsed_conn["channel_id"])
|
|
else:
|
|
conn_channel = conn_media_id.split("_", 1)[0]
|
|
if conn_channel == req_channel:
|
|
logger.debug(
|
|
f"[stream limits][{client_id}] Same timeshift channel {req_channel} allowed"
|
|
)
|
|
return True
|
|
|
|
if user_stream_count >= user.stream_limit:
|
|
if user_limit_settings.get("terminate_on_limit_exceeded", True) == False:
|
|
return False
|
|
|
|
if user_stream_count >= user.stream_limit:
|
|
logger.warning("[stream limits]"
|
|
f"[{client_id}] User {user.username} (ID: {user.id}) has reached stream limit "
|
|
f"({user_stream_count}/{user.stream_limit} streams), attempting to free up slot"
|
|
)
|
|
|
|
if not attempt_stream_termination(user.id, client_id, active_connections):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
_TS_PACKET_SIZE = 188
|
|
_TS_SYNC_BYTE = 0x47
|
|
|
|
|
|
def find_ts_sync(buf):
|
|
"""Return byte offset of the first valid MPEG-TS sync chain in *buf*, or -1.
|
|
|
|
Args:
|
|
buf: Raw bytes from an upstream HTTP response (typically the first 1 KB).
|
|
|
|
Returns:
|
|
Offset of the first 0x47 byte that starts three consecutive 188-byte
|
|
packets, or -1. Used to strip PHP/HTML preamble before streaming.
|
|
"""
|
|
end = len(buf) - 2 * _TS_PACKET_SIZE
|
|
for i in range(0, end):
|
|
if (
|
|
buf[i] == _TS_SYNC_BYTE
|
|
and buf[i + _TS_PACKET_SIZE] == _TS_SYNC_BYTE
|
|
and buf[i + 2 * _TS_PACKET_SIZE] == _TS_SYNC_BYTE
|
|
):
|
|
return i
|
|
return -1
|