mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
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
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:
parent
c5e5016728
commit
562393b77e
5 changed files with 125 additions and 14 deletions
|
|
@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
### Fixed
|
### 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)
|
- **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)
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.decorators import action
|
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 rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
|
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
|
@ -3236,12 +3238,23 @@ def _stop_dvr_clients(channel_uuid, recording_id=None):
|
||||||
return stopped
|
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):
|
class RecordingViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Recording.objects.all()
|
queryset = Recording.objects.all()
|
||||||
serializer_class = RecordingSerializer
|
serializer_class = RecordingSerializer
|
||||||
|
|
||||||
def get_permissions(self):
|
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'):
|
if self.action in ('file', 'hls'):
|
||||||
return [AllowAny()]
|
return [AllowAny()]
|
||||||
try:
|
try:
|
||||||
|
|
@ -3305,7 +3318,12 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response({"success": False, "error": str(e)}, status=400)
|
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):
|
def file(self, request, pk=None):
|
||||||
"""Stream a completed recording file with HTTP Range support for seeking.
|
"""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}\""
|
response["Content-Disposition"] = f"inline; filename=\"{file_name}\""
|
||||||
return response
|
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):
|
def hls(self, request, pk=None, seg_path=None):
|
||||||
"""Serve HLS playlist and segment files for an in-progress (or completed) recording.
|
"""Serve HLS playlist and segment files for an in-progress (or completed) recording.
|
||||||
|
|
||||||
|
|
|
||||||
71
apps/channels/tests/test_recording_playback_auth.py
Normal file
71
apps/channels/tests/test_recording_playback_auth.py
Normal 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)
|
||||||
|
|
@ -16,6 +16,20 @@ import {
|
||||||
savePlayerPrefs,
|
savePlayerPrefs,
|
||||||
} from '../utils/components/FloatingVideoUtils.js';
|
} 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 ResizeHandles = ({ startResize }) => {
|
||||||
const HANDLE_SIZE = 18;
|
const HANDLE_SIZE = 18;
|
||||||
const HANDLE_OFFSET = 0;
|
const HANDLE_OFFSET = 0;
|
||||||
|
|
@ -397,11 +411,11 @@ export default function FloatingVideo() {
|
||||||
}
|
}
|
||||||
} else if (isHls && video.canPlayType('application/vnd.apple.mpegurl')) {
|
} else if (isHls && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
// Safari path: native HLS support, including seekable DVR windows.
|
// Safari path: native HLS support, including seekable DVR windows.
|
||||||
video.src = streamUrl;
|
video.src = withRecordingAuthToken(streamUrl);
|
||||||
video.load();
|
video.load();
|
||||||
} else {
|
} else {
|
||||||
// Plain progressive file (MKV/MP4): native HTML5.
|
// Plain progressive file (MKV/MP4): native HTML5.
|
||||||
video.src = streamUrl;
|
video.src = withRecordingAuthToken(streamUrl);
|
||||||
video.load();
|
video.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -272,34 +272,36 @@ describe('RecordingCardUtils', () => {
|
||||||
|
|
||||||
describe('getRecordingUrl', () => {
|
describe('getRecordingUrl', () => {
|
||||||
it('returns file_url when available', () => {
|
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');
|
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', () => {
|
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');
|
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', () => {
|
it('prefers file_url over output_file_url', () => {
|
||||||
const customProps = {
|
const customProps = {
|
||||||
file_url: '/recordings/file.mp4',
|
file_url: '/api/channels/recordings/2/file/',
|
||||||
output_file_url: '/output/file.mp4',
|
output_file_url: '/api/channels/recordings/3/file/',
|
||||||
};
|
};
|
||||||
const result = getRecordingUrl(customProps, 'production');
|
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', () => {
|
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');
|
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', () => {
|
it('does not prepend dev URL for absolute URLs', () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue