Dispatcharr/apps/timeshift/stats.py
SergeantPanda 223dff33ed feat(timeshift): add catch-up playback, stats, and admin APIs
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
2026-07-11 17:14:31 +00:00

480 lines
18 KiB
Python

"""Build catch-up (timeshift) connection stats for the admin UI."""
import logging
import re
import time
from django.db import close_old_connections
from datetime import timezone as dt_timezone
from apps.channels.models import Channel
from apps.m3u.models import M3UAccountProfile
from apps.proxy.live_proxy.constants import ChannelMetadataField
from apps.timeshift.redis_keys import TimeshiftRedisKeys, parse_stats_channel_id
from apps.timeshift.helpers import parse_catchup_timestamp
logger = logging.getLogger(__name__)
_STREAM_STATS_TO_METADATA = {
"video_codec": ChannelMetadataField.VIDEO_CODEC,
"resolution": ChannelMetadataField.RESOLUTION,
"source_fps": ChannelMetadataField.SOURCE_FPS,
"pixel_format": ChannelMetadataField.PIXEL_FORMAT,
"video_bitrate": ChannelMetadataField.VIDEO_BITRATE,
"audio_codec": ChannelMetadataField.AUDIO_CODEC,
"sample_rate": ChannelMetadataField.SAMPLE_RATE,
"audio_channels": ChannelMetadataField.AUDIO_CHANNELS,
"audio_bitrate": ChannelMetadataField.AUDIO_BITRATE,
"stream_type": ChannelMetadataField.STREAM_TYPE,
}
# Redis metadata → stats API / UI display keys (matches live stats cards).
_METADATA_TO_DISPLAY = {
ChannelMetadataField.RESOLUTION: "resolution",
ChannelMetadataField.SOURCE_FPS: "source_fps",
ChannelMetadataField.VIDEO_CODEC: "video_codec",
ChannelMetadataField.AUDIO_CODEC: "audio_codec",
ChannelMetadataField.AUDIO_CHANNELS: "audio_channels",
ChannelMetadataField.STREAM_TYPE: "stream_type",
}
def stream_stats_to_metadata_fields(stream_stats):
"""Map a ``Stream.stream_stats`` dict to Redis channel metadata fields."""
if not stream_stats:
return {}
out = {}
for src_key, field_name in _STREAM_STATS_TO_METADATA.items():
value = stream_stats.get(src_key)
if value is not None and value != "":
out[field_name] = str(value)
if out:
out[ChannelMetadataField.STREAM_INFO_UPDATED] = str(time.time())
return out
def stream_info_from_metadata(metadata):
"""Extract display-oriented stream stats from Redis channel metadata."""
if not metadata:
return {}
out = {}
for field_name, display_key in _METADATA_TO_DISPLAY.items():
value = metadata.get(field_name)
if value is not None and value != "":
out[display_key] = _decode_value(value)
return out
def seed_stream_stats_metadata(
redis_client,
metadata_key,
metadata_payload,
*,
stats_stream_id,
stream_stats=None,
):
"""Copy stream stats into catch-up Redis metadata when the upstream stream changes.
``stream_stats`` should come from the already-loaded ``catchup_stream`` row
when available. A DB lookup is only attempted when the pool reuses a session
whose metadata was evicted and no in-memory stats were passed.
"""
if stats_stream_id is None or redis_client is None:
return
sid = str(stats_stream_id)
try:
existing = redis_client.hget(metadata_key, ChannelMetadataField.STREAM_ID)
if isinstance(existing, bytes):
existing = existing.decode()
except Exception:
existing = None
if existing == sid:
return
stats = stream_stats
if not stats:
try:
from apps.channels.models import Stream
row = Stream.objects.filter(id=int(sid)).values("stream_stats").first()
stats = row.get("stream_stats") if row else None
except Exception as exc:
logger.debug("Timeshift stream stats lookup failed for %s: %s", sid, exc)
stats = None
fields = stream_stats_to_metadata_fields(stats)
if not fields:
return
metadata_payload.update(fields)
metadata_payload[ChannelMetadataField.STREAM_ID] = sid
def _decode_value(value):
if isinstance(value, bytes):
return value.decode()
return value
def _decode_hash(data):
if not data:
return {}
return {_decode_value(k): _decode_value(v) for k, v in data.items()}
def compute_playback_base_from_byte_range(range_start, content_length, duration_secs):
"""Map a byte offset into an approximate programme position in seconds."""
if range_start is None or range_start <= 0:
return None
try:
range_start = int(range_start)
content_length = int(content_length)
duration_secs = float(duration_secs)
except (TypeError, ValueError):
return None
if content_length <= 0 or duration_secs <= 0:
return None
ratio = min(1.0, max(0.0, range_start / content_length))
return ratio * duration_secs
def resolve_stats_playback_fields(
*,
timestamp_utc,
existing_programme_start,
existing_position_anchor,
existing_playback_base,
range_start,
representation_length,
programme_duration_secs,
now,
):
"""Return ``(playback_base_secs, position_anchor_at)`` for stats registration.
``playback_base_secs`` is set for byte-range seeks (VLC); ``None`` means
derive position from the URL programme timestamp instead.
"""
if isinstance(existing_programme_start, bytes):
existing_programme_start = existing_programme_start.decode()
if isinstance(existing_position_anchor, bytes):
existing_position_anchor = existing_position_anchor.decode()
if isinstance(existing_playback_base, bytes):
existing_playback_base = existing_playback_base.decode()
programme_changed = (
existing_programme_start is not None
and existing_programme_start != timestamp_utc
)
byte_base = compute_playback_base_from_byte_range(
range_start, representation_length, programme_duration_secs,
)
if programme_changed:
return None, now
if byte_base is not None:
return byte_base, now
if range_start == 0:
return None, now
if existing_programme_start == timestamp_utc and existing_position_anchor:
existing_base = None
if existing_playback_base is not None:
try:
existing_base = float(existing_playback_base)
except (TypeError, ValueError):
existing_base = None
return existing_base, existing_position_anchor
return None, now
def compute_playback_position_secs(
programme_start_url,
epg_start_iso,
position_anchor_at,
current_time,
duration_secs=None,
playback_base_secs=None,
):
"""Best-effort catch-up play position in seconds within the programme.
IPTV clients seek by opening a new catch-up URL at the target time, so the
URL timestamp (``programme_start_url``) is the requested position. Offset it
from the programme's EPG start, then advance by wall-clock since the current
stream opened (``position_anchor_at``).
Native players (VLC) often keep the programme URL fixed and seek via
``Range: bytes=…``; in that case ``playback_base_secs`` carries the mapped
position at the anchor instead of the URL timestamp offset.
"""
elapsed_since_anchor = 0.0
if position_anchor_at:
try:
elapsed_since_anchor = max(0.0, current_time - float(position_anchor_at))
except (TypeError, ValueError):
elapsed_since_anchor = 0.0
if playback_base_secs is not None:
try:
position = float(playback_base_secs) + elapsed_since_anchor
except (TypeError, ValueError):
position = elapsed_since_anchor
else:
if not programme_start_url or not epg_start_iso:
return None
url_dt = parse_catchup_timestamp(programme_start_url)
if url_dt is None:
return None
try:
from datetime import datetime
epg_dt = datetime.fromisoformat(epg_start_iso)
except (TypeError, ValueError):
return None
if epg_dt.tzinfo is not None:
epg_dt = epg_dt.astimezone(dt_timezone.utc).replace(tzinfo=None)
url_offset = (url_dt - epg_dt).total_seconds()
position = url_offset + elapsed_since_anchor
if position < 0:
position = 0.0
if duration_secs:
position = min(position, float(duration_secs))
return position
def find_stats_channel_for_session(redis_client, session_id):
"""Locate the Redis stats channel id for a catch-up session."""
if not redis_client or not session_id:
return None
pattern = f"timeshift:channel:*:clients:{session_id}"
cursor = 0
while True:
cursor, keys = redis_client.scan(cursor, match=pattern, count=100)
for key in keys:
key_str = _decode_value(key)
match = re.match(r"timeshift:channel:(.+):clients:.+", key_str)
if match:
return match.group(1)
if cursor == 0:
break
return None
def build_timeshift_stats_data(redis_client):
"""Build catch-up stats payload from Redis session metadata."""
empty = {
"timeshift_sessions": [],
"total_connections": 0,
"timestamp": time.time(),
}
if redis_client is None:
return empty
try:
current_time = time.time()
connections = []
cursor = 0
metadata_pattern = "timeshift:channel:*:metadata"
while True:
cursor, keys = redis_client.scan(
cursor, match=metadata_pattern, count=100,
)
for key in keys:
key_str = _decode_value(key)
match = re.search(r"timeshift:channel:(.+):metadata", key_str)
if not match:
continue
stats_channel_id = match.group(1)
parsed_stats = parse_stats_channel_id(stats_channel_id)
if not parsed_stats:
continue
metadata = _decode_hash(redis_client.hgetall(key))
client_set_key = TimeshiftRedisKeys.clients(stats_channel_id)
client_ids = redis_client.smembers(client_set_key) or []
if not client_ids:
continue
init_time = float(metadata.get(ChannelMetadataField.INIT_TIME, 0) or 0)
uptime = current_time - init_time if init_time > 0 else 0
total_bytes = int(metadata.get(ChannelMetadataField.TOTAL_BYTES, 0) or 0)
avg_bitrate_kbps = 0
if uptime > 0 and total_bytes > 0:
avg_bitrate_kbps = (total_bytes * 8) / uptime / 1000
logo_id = metadata.get(ChannelMetadataField.LOGO_ID)
m3u_profile_id = metadata.get(ChannelMetadataField.M3U_PROFILE)
channel_name = metadata.get(
ChannelMetadataField.CHANNEL_NAME, "Catch-up",
)
channel_id_raw = metadata.get(ChannelMetadataField.CHANNEL_ID)
if not channel_id_raw:
channel_id_raw = str(parsed_stats["channel_id"])
channel_uuid = metadata.get(ChannelMetadataField.CHANNEL_UUID, "")
stream_info = stream_info_from_metadata(metadata)
for raw_client_id in client_ids:
client_id = _decode_value(raw_client_id)
client_key = TimeshiftRedisKeys.client_metadata(
stats_channel_id, client_id,
)
client_data = _decode_hash(redis_client.hgetall(client_key))
if not client_data:
continue
programme_start = client_data.get("programme_start")
if not programme_start:
continue
try:
channel_id = int(channel_id_raw)
except (TypeError, ValueError):
continue
playback_base_raw = client_data.get("playback_base_secs")
playback_base_secs = None
if playback_base_raw is not None and playback_base_raw != "":
try:
playback_base_secs = float(playback_base_raw)
except (TypeError, ValueError):
playback_base_secs = None
connected_at = client_data.get("connected_at")
duration = 0
if connected_at:
try:
duration = int(current_time - float(connected_at))
except (TypeError, ValueError):
duration = 0
connections.append({
"stats_channel_id": stats_channel_id,
"session_id": parsed_stats["session_id"],
"client_id": client_id,
"channel_id": channel_id,
"channel_uuid": channel_uuid,
"channel_name": channel_name,
"logo_id": int(logo_id) if logo_id else None,
"programme_start": programme_start,
"position_anchor_at": client_data.get("position_anchor_at"),
"playback_base_secs": playback_base_secs,
"m3u_profile_id": int(m3u_profile_id) if m3u_profile_id else None,
"ip_address": client_data.get("ip_address", "Unknown"),
"user_agent": client_data.get("user_agent", "unknown"),
"user_id": client_data.get("user_id", "0"),
"username": client_data.get("username", "unknown"),
"connected_at": float(connected_at) if connected_at else None,
"duration": duration,
"bytes_streamed": total_bytes,
"avg_bitrate_kbps": round(avg_bitrate_kbps, 2),
"uptime": uptime,
**stream_info,
})
if cursor == 0:
break
channel_ids = {conn["channel_id"] for conn in connections if conn.get("channel_id")}
channels_by_id = {}
if channel_ids:
for channel in Channel.objects.select_related("logo", "epg_data").filter(
id__in=channel_ids,
):
channels_by_id[channel.id] = channel
profile_ids = {
conn["m3u_profile_id"]
for conn in connections
if conn.get("m3u_profile_id")
}
profiles_by_id = {}
if profile_ids:
for profile in M3UAccountProfile.objects.select_related("m3u_account").filter(
id__in=profile_ids,
):
profiles_by_id[profile.id] = profile
session_stats = {}
for conn in connections:
session_key = conn["session_id"]
channel = channels_by_id.get(conn["channel_id"])
if channel:
conn["channel_name"] = channel.name
if channel.logo_id:
conn["logo_id"] = channel.logo_id
if channel.logo:
conn["logo_url"] = channel.logo.url
profile = profiles_by_id.get(conn.get("m3u_profile_id"))
if profile:
conn["m3u_profile"] = {
"profile_name": profile.name,
"account_name": profile.m3u_account.name,
"account_id": profile.m3u_account.id,
"m3u_profile_id": profile.id,
}
position_anchor_at = conn.get("position_anchor_at")
try:
position_anchor_at = (
float(position_anchor_at) if position_anchor_at else None
)
except (TypeError, ValueError):
position_anchor_at = None
playback_base_secs = conn.get("playback_base_secs")
if session_key not in session_stats:
session_stats[session_key] = {
"session_id": session_key,
"stats_channel_id": conn["stats_channel_id"],
"channel_id": conn["channel_id"],
"channel_uuid": conn.get("channel_uuid", ""),
"channel_name": conn["channel_name"],
"logo_id": conn.get("logo_id"),
"logo_url": conn.get("logo_url"),
"programme_start": conn["programme_start"],
"position_anchor_at": position_anchor_at,
"playback_base_secs": playback_base_secs,
"resolution": conn.get("resolution"),
"source_fps": conn.get("source_fps"),
"video_codec": conn.get("video_codec"),
"audio_codec": conn.get("audio_codec"),
"audio_channels": conn.get("audio_channels"),
"stream_type": conn.get("stream_type"),
"connection_count": 0,
"connections": [],
}
session_stats[session_key]["connection_count"] += 1
session_stats[session_key]["connections"].append({
"client_id": conn["client_id"],
"session_id": conn["session_id"],
"ip_address": conn["ip_address"],
"user_agent": conn["user_agent"],
"user_id": conn["user_id"],
"username": conn["username"],
"connected_at": conn["connected_at"],
"duration": conn["duration"],
"bytes_streamed": conn["bytes_streamed"],
"avg_bitrate_kbps": conn["avg_bitrate_kbps"],
"m3u_profile": conn.get("m3u_profile", {}),
"m3u_profile_id": conn.get("m3u_profile_id"),
})
return {
"timeshift_sessions": list(session_stats.values()),
"total_connections": len(connections),
"timestamp": current_time,
}
except Exception as exc:
logger.error("Error building timeshift stats: %s", exc, exc_info=True)
return empty
finally:
close_old_connections()