Dispatcharr/apps/timeshift/sessions.py
SergeantPanda 14bfd25d9a feat(timeshift): enhance catch-up session handling with optional duration support
- Introduced an optional `duration` parameter for catch-up sessions, allowing clients to specify the programme length in minutes. This value is preferred over EPG-derived durations and includes a buffer for provider lag.
- Updated the API views and serializers to accept and process the new `duration` field.
- Enhanced the catch-up proxy implementation to utilize the client-supplied duration, improving playback accuracy.
- Added tests to validate the new duration handling and ensure proper integration with existing functionality.
2026-07-16 21:26:06 +00:00

203 lines
6.6 KiB
Python

"""Redis-backed catch-up playback sessions for native API clients.
A session is minted by ``POST /api/catchup/sessions/`` (JWT/API key). The
returned ``session_id`` lets a headerless video player call
``GET /proxy/catchup/{uuid}?session_id=...`` without embedding a JWT in the URL.
Lifecycle:
* **Handshake**: unused sessions expire ``HANDSHAKE_TTL_SECONDS`` after POST.
* **Playback**: the first GET extends TTL to ``SESSION_IDLE_TTL_SECONDS``;
each subsequent GET (or active-stream heartbeat) refreshes that sliding
window. Pausing longer than this without a new request requires minting
a new session.
* **End of viewing**: when the client disconnects for real (not a seek within
the same session), the playback layer deletes the record after a short grace
window so stale ``session_id`` values cannot be replayed until TTL expiry.
* **User resolution**: prefer ``timeshift:pool:{session_id}.user_id`` while
the provider pool entry exists; fall back to the API session record when the
pool is idle/expired (pause gaps between HTTP range requests).
"""
import logging
import time
from apps.accounts.models import User
from apps.channels.models import Channel
from apps.timeshift.redis_keys import TimeshiftRedisKeys, mint_session_id
from core.utils import RedisClient
logger = logging.getLogger(__name__)
HANDSHAKE_TTL_SECONDS = 60
# Max idle pause between range/seek requests (refreshed on each playback GET).
SESSION_IDLE_TTL_SECONDS = 10 * 60
def mint_catchup_session_id():
"""Backward-compatible alias for :func:`mint_session_id`."""
return mint_session_id()
def create_catchup_session(*, user, channel, start, duration=None):
"""Persist a new playback session and return metadata for the API response.
``duration`` is an optional programme length in minutes. When supplied it is
preferred over EPG at playback time (see ``resolve_catchup_duration``).
"""
redis_client = RedisClient.get_client()
if redis_client is None:
raise RuntimeError("Redis unavailable")
session_id = mint_session_id()
now = int(time.time())
key = TimeshiftRedisKeys.api_session(session_id)
mapping = {
"user_id": str(user.id),
"channel_uuid": str(channel.uuid),
"channel_id": str(channel.id),
"start": str(start),
"created_at": str(now),
}
if duration is not None:
mapping["duration"] = str(duration)
redis_client.hset(key, mapping=mapping)
redis_client.expire(key, HANDSHAKE_TTL_SECONDS)
handshake_expires_at = now + HANDSHAKE_TTL_SECONDS
playback_url = f"/proxy/catchup/{channel.uuid}?session_id={session_id}"
return {
"session_id": session_id,
"playback_url": playback_url,
"expires_at": handshake_expires_at,
"channel_uuid": str(channel.uuid),
"start": str(start),
"duration": duration,
}
def get_catchup_session(session_id):
"""Return session fields as a dict, or None if missing."""
redis_client = RedisClient.get_client()
if redis_client is None or not session_id:
return None
try:
data = redis_client.hgetall(TimeshiftRedisKeys.api_session(session_id))
except Exception as exc:
logger.warning("Catchup session read failed for %s: %s", session_id, exc)
return None
if not data:
return None
return data
def touch_catchup_session(session_id, *, redis_client=None):
"""Extend sliding idle TTL after a playback request uses the session."""
if redis_client is None:
redis_client = RedisClient.get_client()
if redis_client is None or not session_id:
return False
key = TimeshiftRedisKeys.api_session(session_id)
try:
if not redis_client.exists(key):
return False
redis_client.expire(key, SESSION_IDLE_TTL_SECONDS)
return True
except Exception as exc:
logger.warning("Catchup session touch failed for %s: %s", session_id, exc)
return False
def delete_catchup_session(session_id, *, redis_client=None):
if not session_id:
return False
if redis_client is None:
redis_client = RedisClient.get_client()
if redis_client is None:
return False
try:
deleted = bool(redis_client.delete(TimeshiftRedisKeys.api_session(session_id)))
if deleted:
logger.debug("Catchup session deleted: %s", session_id)
return deleted
except Exception as exc:
logger.warning("Catchup session delete failed for %s: %s", session_id, exc)
return False
def catchup_session_exists(session_id, *, redis_client=None):
"""Return True when *session_id* has an API session record."""
if not session_id:
return False
if redis_client is None:
redis_client = RedisClient.get_client()
if redis_client is None:
return False
try:
return bool(redis_client.exists(TimeshiftRedisKeys.api_session(session_id)))
except Exception:
return False
def _user_id_from_pool(session_id):
redis_client = RedisClient.get_client()
if redis_client is None or not session_id:
return None
try:
data = redis_client.hgetall(TimeshiftRedisKeys.pool(session_id))
except Exception:
return None
if not data:
return None
uid = data.get("user_id")
if not uid:
return None
try:
return int(uid)
except (TypeError, ValueError):
return None
def resolve_catchup_playback(session_id, channel_uuid):
"""Resolve user and programme start for a tokenless playback request.
Returns:
``(user, start, duration)`` on success, or ``None`` if the session is
invalid, expired, or bound to a different channel. ``duration`` is the
stored client programme length in minutes, or ``None`` when unset.
"""
record = get_catchup_session(session_id)
if not record:
return None
if str(record.get("channel_uuid") or "") != str(channel_uuid):
return None
touch_catchup_session(session_id)
user_id = _user_id_from_pool(session_id)
if user_id is None:
try:
user_id = int(record.get("user_id") or "")
except (TypeError, ValueError):
return None
user = User.objects.filter(id=user_id, is_active=True).first()
if user is None:
return None
start = record.get("start")
if not start:
return None
return user, str(start), record.get("duration")
def user_owns_catchup_session(session_id, user_id):
record = get_catchup_session(session_id)
if not record:
return False
try:
return int(record.get("user_id") or "") == int(user_id)
except (TypeError, ValueError):
return False