mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-19 17:47:40 +00:00
This update introduces a new `is_catchup_enabled` function to determine if catch-up is allowed for users based on their custom properties and system settings. The `UserViewSet` is modified to restrict admin-managed properties, including catch-up access. Additionally, various views and tests are updated to incorporate catch-up checks, ensuring that users without access receive appropriate error responses. The frontend is enhanced with a catch-up toggle in user and system settings forms, allowing for better management of catch-up capabilities.
280 lines
11 KiB
Python
280 lines
11 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, is_catchup_enabled
|
|
from core.utils import RedisClient
|
|
from dispatcharr.utils import network_access_allowed
|
|
|
|
from .helpers import MAX_DURATION_MINUTES, parse_catchup_timestamp
|
|
from .sessions import (
|
|
HANDSHAKE_TTL_SECONDS,
|
|
SESSION_IDLE_TTL_SECONDS,
|
|
create_catchup_session,
|
|
delete_catchup_session,
|
|
user_owns_catchup_session,
|
|
)
|
|
from .stats import update_catchup_session_position
|
|
from .views import _trigger_timeshift_stats_update, _user_can_access_channel
|
|
|
|
# Programme length cap expressed in seconds for position reports.
|
|
_MAX_POSITION_SECS = MAX_DURATION_MINUTES * 60
|
|
|
|
|
|
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."
|
|
),
|
|
)
|
|
duration = serializers.IntegerField(
|
|
required=False,
|
|
min_value=1,
|
|
max_value=MAX_DURATION_MINUTES,
|
|
help_text=(
|
|
"Optional programme length in minutes. Preferred over EPG when "
|
|
"supplied. A short buffer is added for provider archive lag. "
|
|
"Omit to derive the length from EPG."
|
|
),
|
|
)
|
|
|
|
|
|
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()
|
|
duration = serializers.IntegerField(
|
|
required=False,
|
|
allow_null=True,
|
|
help_text="Programme length in minutes if supplied at creation, else null.",
|
|
)
|
|
|
|
|
|
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"
|
|
"**``duration``** is optional programme length in minutes. Native "
|
|
"clients should send it when their guide knows the programme "
|
|
"length. Dispatcharr uses it before local EPG duration, adds a "
|
|
"short provider-lag buffer, and falls back to EPG duration, then "
|
|
"the default archive window when omitted.\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)
|
|
|
|
if not is_catchup_enabled(user=user):
|
|
return Response(
|
|
{"error": "Catch-up is disabled"},
|
|
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,
|
|
duration=body.validated_data.get("duration"),
|
|
)
|
|
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)
|
|
|
|
|
|
class CatchupSessionPositionSerializer(serializers.Serializer):
|
|
position_secs = serializers.FloatField(
|
|
min_value=0,
|
|
max_value=_MAX_POSITION_SECS,
|
|
help_text=(
|
|
"Current playhead within the programme, in seconds from programme start."
|
|
),
|
|
)
|
|
paused = serializers.BooleanField(
|
|
required=False,
|
|
help_text=(
|
|
"When true, admin stats freeze at ``position_secs`` (no wall-clock "
|
|
"advance). When false, clear pause. Omit to leave pause state unchanged."
|
|
),
|
|
)
|
|
|
|
|
|
class CatchupSessionPositionAPIView(APIView):
|
|
"""Native clients report local playhead / pause for catch-up stats polish."""
|
|
|
|
permission_classes = [IsStandardUser]
|
|
|
|
@extend_schema(
|
|
description=(
|
|
"Update the reported playhead for an active catch-up session.\n\n"
|
|
"Native apps can call this while paused or after local scrubbing so "
|
|
"admin stats stay aligned with what the viewer sees. This does "
|
|
"**not** seek the provider stream; HTTP ``Range`` / a new archive "
|
|
"open still control bytes.\n\n"
|
|
"Requires an active playback connection for ``session_id``. Also "
|
|
"refreshes the session idle TTL."
|
|
),
|
|
request=CatchupSessionPositionSerializer,
|
|
responses={
|
|
204: None,
|
|
400: inline_serializer(
|
|
name="CatchupSessionPositionError",
|
|
fields={"error": serializers.CharField()},
|
|
),
|
|
403: inline_serializer(
|
|
name="CatchupSessionPositionForbidden",
|
|
fields={"error": serializers.CharField()},
|
|
),
|
|
404: inline_serializer(
|
|
name="CatchupSessionPositionNotFound",
|
|
fields={"error": serializers.CharField()},
|
|
),
|
|
},
|
|
tags=["catchup"],
|
|
)
|
|
def post(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)
|
|
|
|
body = CatchupSessionPositionSerializer(data=request.data)
|
|
body.is_valid(raise_exception=True)
|
|
|
|
updated = update_catchup_session_position(
|
|
session_id,
|
|
position_secs=body.validated_data["position_secs"],
|
|
paused=body.validated_data.get("paused"),
|
|
user_id=request.user.id,
|
|
)
|
|
if not updated:
|
|
return Response(
|
|
{"error": "No active playback for this session"},
|
|
status=status.HTTP_404_NOT_FOUND,
|
|
)
|
|
|
|
_trigger_timeshift_stats_update(RedisClient.get_client())
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|