Dispatcharr/apps/timeshift/api_views.py
SergeantPanda b6442e6421 feat(catchup): enhance catchup functionality with user and system settings
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.
2026-07-18 18:55:51 +00:00

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)