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
|
||||
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
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,
|
||||
} 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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue