mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +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
168 lines
6.3 KiB
Python
168 lines
6.3 KiB
Python
"""REST API for native catch-up playback session setup."""
|
|
|
|
from django.http import Http404
|
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
|
from rest_framework import serializers, status
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from apps.accounts.permissions import IsStandardUser
|
|
from apps.channels.models import Channel
|
|
from apps.channels.utils import get_channel_catchup_streams
|
|
from dispatcharr.utils import network_access_allowed
|
|
|
|
from .helpers import parse_catchup_timestamp
|
|
from .sessions import (
|
|
HANDSHAKE_TTL_SECONDS,
|
|
SESSION_IDLE_TTL_SECONDS,
|
|
create_catchup_session,
|
|
delete_catchup_session,
|
|
user_owns_catchup_session,
|
|
)
|
|
from .views import _user_can_access_channel
|
|
|
|
|
|
class CatchupSessionCreateSerializer(serializers.Serializer):
|
|
channel_uuid = serializers.UUIDField(
|
|
help_text="Dispatcharr channel UUID to play catch-up from.",
|
|
)
|
|
start = serializers.CharField(
|
|
help_text=(
|
|
"UTC programme start time (which archived show to play). "
|
|
"ISO-8601 (2026-07-09T14:00:00Z), Unix epoch, or XC wall-clock shapes."
|
|
),
|
|
)
|
|
|
|
|
|
class CatchupSessionResponseSerializer(serializers.Serializer):
|
|
session_id = serializers.CharField()
|
|
playback_url = serializers.CharField()
|
|
expires_at = serializers.IntegerField(
|
|
help_text="Unix timestamp. Handshake deadline: first playback GET within this window.",
|
|
)
|
|
channel_uuid = serializers.UUIDField()
|
|
start = serializers.CharField()
|
|
|
|
|
|
class CatchupSessionCreateAPIView(APIView):
|
|
"""Mint a server-side playback session for headerless video players."""
|
|
|
|
permission_classes = [IsStandardUser]
|
|
|
|
@extend_schema(
|
|
description=(
|
|
"Create a catch-up playback session for a single archived programme.\n\n"
|
|
"Call this once per programme with a JWT or API key. The response "
|
|
"includes a ``playback_url`` for the video player **without** an "
|
|
"embedded access token.\n\n"
|
|
"**``start``** is the programme's broadcast start time in UTC "
|
|
"(from EPG ``start_time``). It selects *which* archived show to "
|
|
"fetch, not when the viewer pressed play.\n\n"
|
|
f"The player should open ``playback_url`` within "
|
|
f"**{HANDSHAKE_TTL_SECONDS} seconds** (see ``expires_at``). After the "
|
|
"first byte request, the session stays valid with a "
|
|
f"**{SESSION_IDLE_TTL_SECONDS // 60}-minute sliding idle window** "
|
|
"(refreshed on each range/seek request).\n\n"
|
|
"Start a **new** session for each different programme."
|
|
),
|
|
request=CatchupSessionCreateSerializer,
|
|
responses={
|
|
201: CatchupSessionResponseSerializer,
|
|
400: inline_serializer(
|
|
name="CatchupSessionCreateError",
|
|
fields={"error": serializers.CharField()},
|
|
),
|
|
403: inline_serializer(
|
|
name="CatchupSessionForbidden",
|
|
fields={"error": serializers.CharField()},
|
|
),
|
|
404: inline_serializer(
|
|
name="CatchupSessionNotFound",
|
|
fields={"error": serializers.CharField()},
|
|
),
|
|
},
|
|
tags=["catchup"],
|
|
)
|
|
def post(self, request):
|
|
user = request.user
|
|
if not network_access_allowed(request, "STREAMS", user):
|
|
return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN)
|
|
|
|
body = CatchupSessionCreateSerializer(data=request.data)
|
|
body.is_valid(raise_exception=True)
|
|
|
|
start = body.validated_data["start"].strip()
|
|
if parse_catchup_timestamp(start) is None:
|
|
return Response(
|
|
{"error": "Invalid start timestamp"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
channel_uuid = body.validated_data["channel_uuid"]
|
|
try:
|
|
channel = Channel.objects.get(uuid=channel_uuid)
|
|
except Channel.DoesNotExist:
|
|
raise Http404("Channel not found") from None
|
|
|
|
if not _user_can_access_channel(user, channel):
|
|
return Response({"error": "Access denied"}, status=status.HTTP_403_FORBIDDEN)
|
|
|
|
if not channel.is_catchup:
|
|
return Response(
|
|
{"error": "Catch-up not supported for this channel"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if not get_channel_catchup_streams(channel):
|
|
return Response(
|
|
{"error": "Catch-up not supported for this channel"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
payload = create_catchup_session(user=user, channel=channel, start=start)
|
|
except RuntimeError:
|
|
return Response(
|
|
{"error": "Session service unavailable"},
|
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
)
|
|
|
|
return Response(
|
|
CatchupSessionResponseSerializer(payload).data,
|
|
status=status.HTTP_201_CREATED,
|
|
)
|
|
|
|
|
|
class CatchupSessionDestroyAPIView(APIView):
|
|
"""Revoke a catch-up playback session early."""
|
|
|
|
permission_classes = [IsStandardUser]
|
|
|
|
@extend_schema(
|
|
description=(
|
|
"Delete a catch-up session before it expires. Only the user who "
|
|
"created the session may revoke it. Returns 404 when the session "
|
|
"is missing or owned by another user."
|
|
),
|
|
responses={
|
|
204: None,
|
|
403: inline_serializer(
|
|
name="CatchupSessionDestroyForbidden",
|
|
fields={"error": serializers.CharField()},
|
|
),
|
|
404: inline_serializer(
|
|
name="CatchupSessionDestroyNotFound",
|
|
fields={"error": serializers.CharField()},
|
|
),
|
|
},
|
|
tags=["catchup"],
|
|
)
|
|
def delete(self, request, session_id):
|
|
if not network_access_allowed(request, "STREAMS", request.user):
|
|
return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN)
|
|
|
|
if not user_owns_catchup_session(session_id, request.user.id):
|
|
return Response({"error": "Session not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
delete_catchup_session(session_id)
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|