fix(recording playback): Enhance completed DVR recording playback by allowing JWT token via query parameter for native <video src> support. Updated authentication handling in API views and modified FloatingVideo component to append token dynamically. Updated tests to reflect new API URL structure.
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run

This commit is contained in:
SergeantPanda 2026-06-24 17:32:48 -05:00
parent c5e5016728
commit 562393b77e
5 changed files with 125 additions and 14 deletions

View file

@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **Completed DVR recordings no longer return 403 when played in the browser.** In-progress recordings used hls.js, which injects the JWT via `xhrSetup`. Completed recordings switch to the `/file/` endpoint with native `<video src>`, which cannot send `Authorization` headers. Recording playback now accepts `?token=` query-param JWT (matching VOD streams), and the floating video player appends the token when assigning native `<video src>` URLs.
- **In-progress DVR playback no longer jumps to the live edge.** The floating video player still opens in-progress recordings at the start of the seekable range, but hls.js was configured with `liveMaxLatencyDurationCount: 10`, which forced the playhead forward to "now" shortly after playback began. Live-edge sync is now disabled for recording HLS so users can watch, pause, and scrub from the beginning while the recording continues. (Fixes #1329)
- **M3U refresh recovers DB connections and account status after failures.** Celery workers now call `close_old_connections()` before and after every task; `refresh_single_m3u_account` also resets its DB connection at task start and retries account loads once after a connection reset (avoids opaque `list index out of range` errors from poisoned worker connections). Accounts leave `fetching`/`parsing` for a terminal `error` or `success` state when refresh aborts. Error-path status updates use `QuerySet.update()` on a clean connection instead of `save()` on a connection that may already be INTRANS after `refresh_m3u_groups` or batch ORM failures. Failed group downloads now set `error` instead of returning silently. Refresh start replaces stale `last_message` text. `refresh_account_profiles` releases DB connections after each profile failure. (Fixes #1338)
- **EPG refresh recovers DB connections and source status after failures.** `refresh_epg_data` now mirrors the M3U hardening: connection reset at task start/end, retried source load after a poisoned-worker connection reset, `QuerySet.update()` for error status on a clean connection, and a `finally` guard that moves sources stuck in `fetching`/`parsing` to `error`. Programme index builds are skipped when programme parsing fails. `parse_channels_only` now persists the error status when a missing file has no URL to refetch.

View file

@ -3,6 +3,8 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
from rest_framework.decorators import action
from rest_framework_simplejwt.authentication import JWTAuthentication
from apps.accounts.authentication import ApiKeyAuthentication, QueryParamJWTAuthentication
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
from drf_spectacular.types import OpenApiTypes
@ -3236,12 +3238,23 @@ def _stop_dvr_clients(channel_uuid, recording_id=None):
return stopped
# QueryParamJWTAuthentication supports native <video src> clients that cannot
# send Authorization headers. Authorization still requires an authenticated
# user via _user_can_play_recording; these classes only populate request.user.
RECORDING_PLAYBACK_AUTHENTICATORS = [
JWTAuthentication,
ApiKeyAuthentication,
QueryParamJWTAuthentication,
]
class RecordingViewSet(viewsets.ModelViewSet):
queryset = Recording.objects.all()
serializer_class = RecordingSerializer
def get_permissions(self):
# Allow unauthenticated playback of recording files (like other streaming endpoints)
# file/hls use AllowAny so DRF does not reject requests before auth
# classes run; _user_can_play_recording enforces authenticated access.
if self.action in ('file', 'hls'):
return [AllowAny()]
try:
@ -3305,7 +3318,12 @@ class RecordingViewSet(viewsets.ModelViewSet):
except Exception as e:
return Response({"success": False, "error": str(e)}, status=400)
@action(detail=True, methods=["get"], url_path="file")
@action(
detail=True,
methods=["get"],
url_path="file",
authentication_classes=RECORDING_PLAYBACK_AUTHENTICATORS,
)
def file(self, request, pk=None):
"""Stream a completed recording file with HTTP Range support for seeking.
@ -3393,7 +3411,12 @@ class RecordingViewSet(viewsets.ModelViewSet):
response["Content-Disposition"] = f"inline; filename=\"{file_name}\""
return response
@action(detail=True, methods=["get"], url_path="hls/(?P<seg_path>.+)")
@action(
detail=True,
methods=["get"],
url_path="hls/(?P<seg_path>.+)",
authentication_classes=RECORDING_PLAYBACK_AUTHENTICATORS,
)
def hls(self, request, pk=None, seg_path=None):
"""Serve HLS playlist and segment files for an in-progress (or completed) recording.

View file

@ -0,0 +1,71 @@
"""Tests for DVR recording playback authentication (file/hls endpoints)."""
import os
import tempfile
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIRequestFactory
from rest_framework_simplejwt.tokens import RefreshToken
from apps.channels.api_views import RecordingViewSet
from apps.channels.models import Channel, Recording
def _make_admin():
from django.contrib.auth import get_user_model
User = get_user_model()
user, _ = User.objects.get_or_create(
username="recording_playback_admin",
defaults={"user_level": User.UserLevel.ADMIN},
)
user.set_password("pass")
user.save()
return user
@override_settings(ALLOWED_HOSTS=["testserver"])
class RecordingPlaybackAuthTests(TestCase):
def setUp(self):
self.channel = Channel.objects.create(channel_number=42, name="Playback Auth Channel")
self.user = _make_admin()
self.factory = APIRequestFactory()
self.tmp = tempfile.NamedTemporaryFile(suffix=".mkv", delete=False)
self.tmp.write(b"\x00" * 1024)
self.tmp.close()
now = timezone.now()
self.recording = Recording.objects.create(
channel=self.channel,
start_time=now,
end_time=now,
custom_properties={
"status": "completed",
"file_path": self.tmp.name,
"file_name": "test.mkv",
},
)
def tearDown(self):
if os.path.exists(self.tmp.name):
os.unlink(self.tmp.name)
def _file(self, *, token=None):
url = f"/api/channels/recordings/{self.recording.id}/file/"
if token:
url = f"{url}?token={token}"
request = self.factory.get(url)
view = RecordingViewSet.as_view({"get": "file"})
return view(request, pk=self.recording.id)
@staticmethod
def _jwt_for(user):
return str(RefreshToken.for_user(user).access_token)
def test_file_requires_authentication(self):
response = self._file()
self.assertEqual(response.status_code, 403)
def test_file_accepts_jwt_query_param(self):
token = self._jwt_for(self.user)
response = self._file(token=token)
self.assertEqual(response.status_code, 200)

View file

@ -16,6 +16,20 @@ import {
savePlayerPrefs,
} from '../utils/components/FloatingVideoUtils.js';
// Native <video src> cannot send Authorization headers. Append ?token= at playback
// time (not when building the URL in cards/modals) so the JWT is fresh. hls.js
// paths authenticate via xhrSetup instead and do not need this helper.
const withRecordingAuthToken = (url) => {
if (!url || typeof url !== 'string') return url;
if (!url.includes('/api/channels/recordings/')) return url;
const token = useAuthStore.getState().accessToken;
if (!token) return url;
const [base, query = ''] = url.split('?');
const params = new URLSearchParams(query);
params.set('token', token);
return `${base}?${params.toString()}`;
};
const ResizeHandles = ({ startResize }) => {
const HANDLE_SIZE = 18;
const HANDLE_OFFSET = 0;
@ -397,11 +411,11 @@ export default function FloatingVideo() {
}
} else if (isHls && video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari path: native HLS support, including seekable DVR windows.
video.src = streamUrl;
video.src = withRecordingAuthToken(streamUrl);
video.load();
} else {
// Plain progressive file (MKV/MP4): native HTML5.
video.src = streamUrl;
video.src = withRecordingAuthToken(streamUrl);
video.load();
}

View file

@ -272,34 +272,36 @@ describe('RecordingCardUtils', () => {
describe('getRecordingUrl', () => {
it('returns file_url when available', () => {
const customProps = { file_url: '/recordings/file.mp4' };
const customProps = { file_url: '/api/channels/recordings/67/file/' };
const result = getRecordingUrl(customProps, 'production');
expect(result).toBe('/recordings/file.mp4');
expect(result).toBe('/api/channels/recordings/67/file/');
});
it('returns output_file_url when file_url is not available', () => {
const customProps = { output_file_url: '/output/file.mp4' };
const customProps = { output_file_url: '/api/channels/recordings/1/file/' };
const result = getRecordingUrl(customProps, 'production');
expect(result).toBe('/output/file.mp4');
expect(result).toBe('/api/channels/recordings/1/file/');
});
it('prefers file_url over output_file_url', () => {
const customProps = {
file_url: '/recordings/file.mp4',
output_file_url: '/output/file.mp4',
file_url: '/api/channels/recordings/2/file/',
output_file_url: '/api/channels/recordings/3/file/',
};
const result = getRecordingUrl(customProps, 'production');
expect(result).toBe('/recordings/file.mp4');
expect(result).toBe('/api/channels/recordings/2/file/');
});
it('prepends dev server URL in dev mode for relative paths', () => {
const customProps = { file_url: '/recordings/file.mp4' };
const customProps = { file_url: '/api/channels/recordings/4/hls/index.m3u8' };
const result = getRecordingUrl(customProps, 'dev');
expect(result).toMatch(/^https?:\/\/.*:5656\/recordings\/file\.mp4$/);
expect(result).toMatch(
/^https?:\/\/.*:5656\/api\/channels\/recordings\/4\/hls\/index\.m3u8$/
);
});
it('does not prepend dev URL for absolute URLs', () => {