endoreg-db 0.8.1__py3-none-any.whl → 0.8.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of endoreg-db might be problematic. Click here for more details.
- endoreg_db/helpers/download_segmentation_model.py +31 -0
- endoreg_db/models/media/video/pipe_1.py +13 -1
- endoreg_db/serializers/anonymization.py +3 -0
- endoreg_db/services/pdf_import.py +0 -30
- endoreg_db/services/video_import.py +301 -98
- endoreg_db/urls/__init__.py +0 -2
- endoreg_db/urls/media.py +201 -4
- endoreg_db/urls/report.py +0 -30
- endoreg_db/urls/video.py +30 -88
- endoreg_db/views/anonymization/validate.py +5 -2
- endoreg_db/views/media/__init__.py +38 -2
- endoreg_db/views/media/pdf_media.py +1 -1
- endoreg_db/views/media/segments.py +71 -0
- endoreg_db/views/media/sensitive_metadata.py +314 -0
- endoreg_db/views/media/video_segments.py +596 -0
- endoreg_db/views/pdf/reimport.py +18 -8
- endoreg_db/views/video/__init__.py +0 -8
- endoreg_db/views/video/correction.py +26 -26
- endoreg_db/views/video/reimport.py +15 -12
- endoreg_db/views/video/video_stream.py +168 -50
- {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.dist-info}/METADATA +2 -2
- {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.dist-info}/RECORD +34 -33
- endoreg_db/urls/pdf.py +0 -0
- endoreg_db/urls/sensitive_meta.py +0 -36
- endoreg_db/views/video/media/__init__.py +0 -23
- /endoreg_db/views/video/{media/task_status.py → task_status.py} +0 -0
- /endoreg_db/views/video/{media/video_analyze.py → video_analyze.py} +0 -0
- /endoreg_db/views/video/{media/video_apply_mask.py → video_apply_mask.py} +0 -0
- /endoreg_db/views/video/{media/video_correction.py → video_correction.py} +0 -0
- /endoreg_db/views/video/{media/video_download_processed.py → video_download_processed.py} +0 -0
- /endoreg_db/views/video/{media/video_media.py → video_media.py} +0 -0
- /endoreg_db/views/video/{media/video_meta.py → video_meta.py} +0 -0
- /endoreg_db/views/video/{media/video_processing_history.py → video_processing_history.py} +0 -0
- /endoreg_db/views/video/{media/video_remove_frames.py → video_remove_frames.py} +0 -0
- /endoreg_db/views/video/{media/video_reprocess.py → video_reprocess.py} +0 -0
- {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.dist-info}/WHEEL +0 -0
- {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -121,7 +121,7 @@ def update_segments_after_frame_removal(video: VideoFile, removed_frames: list)
|
|
|
121
121
|
|
|
122
122
|
class VideoMetadataView(APIView):
|
|
123
123
|
"""
|
|
124
|
-
GET /api/
|
|
124
|
+
GET /api/media/videos/{pk}/metadata/
|
|
125
125
|
|
|
126
126
|
Retrieve analysis results for a video.
|
|
127
127
|
|
|
@@ -139,9 +139,9 @@ class VideoMetadataView(APIView):
|
|
|
139
139
|
}
|
|
140
140
|
"""
|
|
141
141
|
|
|
142
|
-
def get(self, request,
|
|
142
|
+
def get(self, request, pk):
|
|
143
143
|
"""Get video metadata by video ID."""
|
|
144
|
-
video = get_object_or_404(VideoFile, pk=
|
|
144
|
+
video = get_object_or_404(VideoFile, pk=pk)
|
|
145
145
|
|
|
146
146
|
# Get or create metadata record
|
|
147
147
|
metadata, created = VideoMetadata.objects.get_or_create(video=video)
|
|
@@ -152,7 +152,7 @@ class VideoMetadataView(APIView):
|
|
|
152
152
|
|
|
153
153
|
class VideoProcessingHistoryView(APIView):
|
|
154
154
|
"""
|
|
155
|
-
GET /api/
|
|
155
|
+
GET /api/media/videos/{pk}/processing-history/
|
|
156
156
|
|
|
157
157
|
Retrieve processing history for a video.
|
|
158
158
|
|
|
@@ -179,9 +179,9 @@ class VideoProcessingHistoryView(APIView):
|
|
|
179
179
|
]
|
|
180
180
|
"""
|
|
181
181
|
|
|
182
|
-
def get(self, request,
|
|
182
|
+
def get(self, request, pk):
|
|
183
183
|
"""Get processing history for a video."""
|
|
184
|
-
video = get_object_or_404(VideoFile, pk=
|
|
184
|
+
video = get_object_or_404(VideoFile, pk=pk)
|
|
185
185
|
|
|
186
186
|
# Get all history records, newest first
|
|
187
187
|
history = VideoProcessingHistory.objects.filter(
|
|
@@ -198,7 +198,7 @@ class VideoProcessingHistoryView(APIView):
|
|
|
198
198
|
|
|
199
199
|
class VideoAnalyzeView(APIView):
|
|
200
200
|
"""
|
|
201
|
-
POST /api/
|
|
201
|
+
POST /api/media/videos/{pk}/analyze/
|
|
202
202
|
|
|
203
203
|
Analyze video for sensitive frames using MiniCPM-o 2.6 or OCR+LLM.
|
|
204
204
|
|
|
@@ -220,9 +220,9 @@ class VideoAnalyzeView(APIView):
|
|
|
220
220
|
}
|
|
221
221
|
"""
|
|
222
222
|
|
|
223
|
-
def post(self, request,
|
|
223
|
+
def post(self, request, pk):
|
|
224
224
|
"""Analyze video for sensitive content."""
|
|
225
|
-
video = get_object_or_404(VideoFile, pk=
|
|
225
|
+
video = get_object_or_404(VideoFile, pk=pk)
|
|
226
226
|
|
|
227
227
|
# Extract parameters
|
|
228
228
|
detection_method = request.data.get('detection_method', 'minicpm')
|
|
@@ -270,7 +270,7 @@ class VideoAnalyzeView(APIView):
|
|
|
270
270
|
details=f"Found {sensitive_count} sensitive frames out of {total_frames} total frames"
|
|
271
271
|
)
|
|
272
272
|
|
|
273
|
-
logger.info(f"Video {
|
|
273
|
+
logger.info(f"Video {pk} analyzed: {sensitive_count}/{total_frames} sensitive frames")
|
|
274
274
|
|
|
275
275
|
return Response({
|
|
276
276
|
'sensitive_frame_count': sensitive_count,
|
|
@@ -283,7 +283,7 @@ class VideoAnalyzeView(APIView):
|
|
|
283
283
|
})
|
|
284
284
|
|
|
285
285
|
except Exception as e:
|
|
286
|
-
logger.error(f"Video analysis failed for {
|
|
286
|
+
logger.error(f"Video analysis failed for {pk}: {str(e)}", exc_info=True)
|
|
287
287
|
|
|
288
288
|
# Create failure record
|
|
289
289
|
VideoProcessingHistory.objects.create(
|
|
@@ -305,7 +305,7 @@ class VideoAnalyzeView(APIView):
|
|
|
305
305
|
|
|
306
306
|
class VideoApplyMaskView(APIView):
|
|
307
307
|
"""
|
|
308
|
-
POST /api/
|
|
308
|
+
POST /api/media/videos/{pk}/apply-mask/
|
|
309
309
|
|
|
310
310
|
Apply device mask or custom ROI mask to video.
|
|
311
311
|
|
|
@@ -333,9 +333,9 @@ class VideoApplyMaskView(APIView):
|
|
|
333
333
|
Note: Currently synchronous. Will be converted to Celery task in Phase 1.2.
|
|
334
334
|
"""
|
|
335
335
|
|
|
336
|
-
def post(self, request,
|
|
336
|
+
def post(self, request, pk):
|
|
337
337
|
"""Apply masking to video."""
|
|
338
|
-
video = get_object_or_404(VideoFile, pk=
|
|
338
|
+
video = get_object_or_404(VideoFile, pk=pk)
|
|
339
339
|
|
|
340
340
|
# Extract parameters
|
|
341
341
|
mask_type = request.data.get('mask_type', 'device')
|
|
@@ -414,7 +414,7 @@ class VideoApplyMaskView(APIView):
|
|
|
414
414
|
details=f"Masking completed in {processing_time:.1f}s"
|
|
415
415
|
)
|
|
416
416
|
|
|
417
|
-
logger.info(f"Video {
|
|
417
|
+
logger.info(f"Video {pk} masked successfully: {output_path}")
|
|
418
418
|
|
|
419
419
|
return Response({
|
|
420
420
|
'task_id': None, # Will be Celery task ID in Phase 1.2
|
|
@@ -426,7 +426,7 @@ class VideoApplyMaskView(APIView):
|
|
|
426
426
|
raise Exception("Masking failed - check FFmpeg logs")
|
|
427
427
|
|
|
428
428
|
except Exception as e:
|
|
429
|
-
logger.error(f"Video masking failed for {
|
|
429
|
+
logger.error(f"Video masking failed for {pk}: {str(e)}", exc_info=True)
|
|
430
430
|
|
|
431
431
|
history.mark_failure(str(e))
|
|
432
432
|
|
|
@@ -438,7 +438,7 @@ class VideoApplyMaskView(APIView):
|
|
|
438
438
|
|
|
439
439
|
class VideoRemoveFramesView(APIView):
|
|
440
440
|
"""
|
|
441
|
-
POST /api/
|
|
441
|
+
POST /api/media/videos/{pk}/remove-frames/
|
|
442
442
|
|
|
443
443
|
Remove specified frames from video.
|
|
444
444
|
|
|
@@ -465,9 +465,9 @@ class VideoRemoveFramesView(APIView):
|
|
|
465
465
|
Note: Currently synchronous. Will be converted to Celery task in Phase 1.2.
|
|
466
466
|
"""
|
|
467
467
|
|
|
468
|
-
def post(self, request,
|
|
468
|
+
def post(self, request, pk):
|
|
469
469
|
"""Remove frames from video."""
|
|
470
|
-
video = get_object_or_404(VideoFile, pk=
|
|
470
|
+
video = get_object_or_404(VideoFile, pk=pk)
|
|
471
471
|
|
|
472
472
|
# Extract parameters
|
|
473
473
|
frame_list = request.data.get('frame_list')
|
|
@@ -571,7 +571,7 @@ class VideoRemoveFramesView(APIView):
|
|
|
571
571
|
details="; ".join(details_parts)
|
|
572
572
|
)
|
|
573
573
|
|
|
574
|
-
logger.info(f"Video {
|
|
574
|
+
logger.info(f"Video {pk} cleaned: removed {len(frames_to_remove)} frames")
|
|
575
575
|
|
|
576
576
|
return Response({
|
|
577
577
|
'task_id': None, # Will be Celery task ID in Phase 1.2
|
|
@@ -585,7 +585,7 @@ class VideoRemoveFramesView(APIView):
|
|
|
585
585
|
raise Exception("Frame removal failed - check FFmpeg logs")
|
|
586
586
|
|
|
587
587
|
except Exception as e:
|
|
588
|
-
logger.error(f"Frame removal failed for {
|
|
588
|
+
logger.error(f"Frame removal failed for {pk}: {str(e)}", exc_info=True)
|
|
589
589
|
|
|
590
590
|
history.mark_failure(str(e))
|
|
591
591
|
|
|
@@ -613,7 +613,7 @@ class VideoRemoveFramesView(APIView):
|
|
|
613
613
|
|
|
614
614
|
class VideoReprocessView(APIView):
|
|
615
615
|
"""
|
|
616
|
-
POST /api/
|
|
616
|
+
POST /api/media/videos/{pk}/reprocess/
|
|
617
617
|
|
|
618
618
|
Re-run entire anonymization pipeline for a video.
|
|
619
619
|
|
|
@@ -628,9 +628,9 @@ class VideoReprocessView(APIView):
|
|
|
628
628
|
Note: This resets VideoState and triggers video_import service.
|
|
629
629
|
"""
|
|
630
630
|
|
|
631
|
-
def post(self, request,
|
|
631
|
+
def post(self, request, pk):
|
|
632
632
|
"""Reprocess video through entire anonymization pipeline."""
|
|
633
|
-
video = get_object_or_404(VideoFile, pk=
|
|
633
|
+
video = get_object_or_404(VideoFile, pk=pk)
|
|
634
634
|
|
|
635
635
|
try:
|
|
636
636
|
# Create processing history record
|
|
@@ -656,7 +656,7 @@ class VideoReprocessView(APIView):
|
|
|
656
656
|
|
|
657
657
|
history.mark_success(details="Reprocessing initiated")
|
|
658
658
|
|
|
659
|
-
logger.info(f"Video {
|
|
659
|
+
logger.info(f"Video {pk} reprocessing started")
|
|
660
660
|
|
|
661
661
|
return Response({
|
|
662
662
|
'message': 'Reprocessing started',
|
|
@@ -664,7 +664,7 @@ class VideoReprocessView(APIView):
|
|
|
664
664
|
})
|
|
665
665
|
|
|
666
666
|
except Exception as e:
|
|
667
|
-
logger.error(f"Reprocessing failed for {
|
|
667
|
+
logger.error(f"Reprocessing failed for {pk}: {str(e)}", exc_info=True)
|
|
668
668
|
|
|
669
669
|
return Response(
|
|
670
670
|
{'error': f'Reprocessing failed: {str(e)}'},
|
|
@@ -19,25 +19,28 @@ class VideoReimportView(APIView):
|
|
|
19
19
|
super().__init__(**kwargs)
|
|
20
20
|
self.video_service = VideoImportService()
|
|
21
21
|
|
|
22
|
-
def post(self, request,
|
|
22
|
+
def post(self, request, pk):
|
|
23
23
|
"""
|
|
24
24
|
Re-import a video file to regenerate SensitiveMeta and other metadata.
|
|
25
25
|
Instead of creating a new video, this updates the existing one.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
pk (int): Primary key of the VideoFile to reimport
|
|
26
29
|
"""
|
|
27
|
-
# Validate
|
|
28
|
-
if not
|
|
30
|
+
# Validate pk parameter
|
|
31
|
+
if not pk or not isinstance(pk, int):
|
|
29
32
|
return Response(
|
|
30
33
|
{"error": "Invalid video ID provided."},
|
|
31
34
|
status=status.HTTP_400_BAD_REQUEST
|
|
32
35
|
)
|
|
33
36
|
|
|
34
37
|
try:
|
|
35
|
-
video = VideoFile.objects.get(id=
|
|
36
|
-
logger.info(f"Found video {video.uuid} (ID: {
|
|
38
|
+
video = VideoFile.objects.get(id=pk)
|
|
39
|
+
logger.info(f"Found video {video.uuid} (ID: {pk}) for re-import")
|
|
37
40
|
except VideoFile.DoesNotExist:
|
|
38
|
-
logger.warning(f"Video with ID {
|
|
41
|
+
logger.warning(f"Video with ID {pk} not found")
|
|
39
42
|
return Response(
|
|
40
|
-
{"error": f"Video with ID {
|
|
43
|
+
{"error": f"Video with ID {pk} not found."},
|
|
41
44
|
status=status.HTTP_404_NOT_FOUND
|
|
42
45
|
)
|
|
43
46
|
|
|
@@ -67,7 +70,7 @@ class VideoReimportView(APIView):
|
|
|
67
70
|
)
|
|
68
71
|
|
|
69
72
|
try:
|
|
70
|
-
logger.info(f"Starting in-place re-import for video {video.uuid} (ID: {
|
|
73
|
+
logger.info(f"Starting in-place re-import for video {video.uuid} (ID: {pk})")
|
|
71
74
|
|
|
72
75
|
with transaction.atomic():
|
|
73
76
|
# Clear existing metadata to force regeneration
|
|
@@ -140,7 +143,7 @@ class VideoReimportView(APIView):
|
|
|
140
143
|
|
|
141
144
|
return Response({
|
|
142
145
|
"message": "Video re-import with VideoImportService completed successfully.",
|
|
143
|
-
"video_id":
|
|
146
|
+
"video_id": pk,
|
|
144
147
|
"uuid": str(video.uuid),
|
|
145
148
|
"frame_cleaning_applied": True,
|
|
146
149
|
"sensitive_meta_created": video.sensitive_meta is not None,
|
|
@@ -161,7 +164,7 @@ class VideoReimportView(APIView):
|
|
|
161
164
|
|
|
162
165
|
return Response({
|
|
163
166
|
"message": "Video re-import completed successfully.",
|
|
164
|
-
"video_id":
|
|
167
|
+
"video_id": pk,
|
|
165
168
|
"uuid": str(video.uuid),
|
|
166
169
|
"sensitive_meta_created": video.sensitive_meta is not None,
|
|
167
170
|
"sensitive_meta_id": video.sensitive_meta.id if video.sensitive_meta else None,
|
|
@@ -179,7 +182,7 @@ class VideoReimportView(APIView):
|
|
|
179
182
|
return Response({
|
|
180
183
|
"error": f"Storage error during re-import: {error_msg}",
|
|
181
184
|
"error_type": "storage_error",
|
|
182
|
-
"video_id":
|
|
185
|
+
"video_id": pk,
|
|
183
186
|
"uuid": str(video.uuid)
|
|
184
187
|
}, status=status.HTTP_507_INSUFFICIENT_STORAGE)
|
|
185
188
|
else:
|
|
@@ -187,6 +190,6 @@ class VideoReimportView(APIView):
|
|
|
187
190
|
return Response({
|
|
188
191
|
"error": f"Re-import failed: {error_msg}",
|
|
189
192
|
"error_type": "processing_error",
|
|
190
|
-
"video_id":
|
|
193
|
+
"video_id": pk,
|
|
191
194
|
"uuid": str(video.uuid)
|
|
192
195
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
@@ -5,61 +5,140 @@ Separate view for streaming raw and processed video files.
|
|
|
5
5
|
Extracted from segmentation.py for better code organization.
|
|
6
6
|
|
|
7
7
|
Created: October 9, 2025
|
|
8
|
+
Updated: October 15, 2025 - Added HTTP 206 Range Request Support
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
|
-
from pathlib import Path
|
|
11
11
|
import os
|
|
12
|
-
import
|
|
12
|
+
import re
|
|
13
13
|
import logging
|
|
14
|
-
|
|
14
|
+
import mimetypes
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Tuple, Optional
|
|
17
|
+
from django.http import FileResponse, Http404, StreamingHttpResponse
|
|
15
18
|
from rest_framework.views import APIView
|
|
16
19
|
|
|
17
20
|
from ...models import VideoFile
|
|
18
21
|
from ...utils.permissions import EnvironmentAwarePermission
|
|
22
|
+
from ...utils.paths import STORAGE_DIR # Import STORAGE_DIR for path resolution
|
|
19
23
|
|
|
20
24
|
logger = logging.getLogger(__name__)
|
|
21
25
|
|
|
22
26
|
|
|
23
|
-
def
|
|
27
|
+
def parse_range_header(range_header: str, file_size: int) -> Tuple[int, int]:
|
|
24
28
|
"""
|
|
25
|
-
|
|
29
|
+
Parse HTTP Range header and return (start, end) byte positions.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
range_header: HTTP Range header value (e.g., "bytes=0-1023")
|
|
33
|
+
file_size: Total file size in bytes
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Tuple of (start_byte, end_byte) inclusive
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If range header is invalid
|
|
40
|
+
"""
|
|
41
|
+
# Expected format: "bytes=start-end" or "bytes=start-"
|
|
42
|
+
match = re.match(r'bytes=(\d+)-(\d*)', range_header)
|
|
43
|
+
|
|
44
|
+
if not match:
|
|
45
|
+
raise ValueError(f"Invalid Range header format: {range_header}")
|
|
46
|
+
|
|
47
|
+
start = int(match.group(1))
|
|
48
|
+
end_str = match.group(2)
|
|
49
|
+
|
|
50
|
+
# If end is not specified, use file size - 1
|
|
51
|
+
end = int(end_str) if end_str else file_size - 1
|
|
52
|
+
|
|
53
|
+
# Validate range
|
|
54
|
+
if start >= file_size or start < 0:
|
|
55
|
+
raise ValueError(f"Start byte {start} is out of range (file size: {file_size})")
|
|
56
|
+
|
|
57
|
+
if end >= file_size:
|
|
58
|
+
end = file_size - 1
|
|
59
|
+
|
|
60
|
+
if start > end:
|
|
61
|
+
raise ValueError(f"Invalid range: start ({start}) > end ({end})")
|
|
62
|
+
|
|
63
|
+
return start, end
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def stream_file_chunk(file_path: Path, start: int, end: int, chunk_size: int = 8192):
|
|
67
|
+
"""
|
|
68
|
+
Generator that yields chunks of a file within the specified byte range.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
file_path: Path to the file
|
|
72
|
+
start: Start byte position (inclusive)
|
|
73
|
+
end: End byte position (inclusive)
|
|
74
|
+
chunk_size: Size of each chunk to yield
|
|
75
|
+
|
|
76
|
+
Yields:
|
|
77
|
+
Bytes chunks from the file
|
|
78
|
+
"""
|
|
79
|
+
with open(file_path, 'rb') as f:
|
|
80
|
+
f.seek(start)
|
|
81
|
+
remaining = end - start + 1 # +1 because end is inclusive
|
|
82
|
+
|
|
83
|
+
while remaining > 0:
|
|
84
|
+
chunk = f.read(min(chunk_size, remaining))
|
|
85
|
+
if not chunk:
|
|
86
|
+
break
|
|
87
|
+
yield chunk
|
|
88
|
+
remaining -= len(chunk)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _stream_video_file(vf: VideoFile, frontend_origin: str, file_type: str = 'raw', range_header: Optional[str] = None) -> FileResponse | StreamingHttpResponse:
|
|
92
|
+
"""
|
|
93
|
+
Helper function to stream a video file with proper headers, CORS support, and HTTP Range Requests.
|
|
26
94
|
|
|
27
95
|
Args:
|
|
28
96
|
vf: VideoFile model instance
|
|
29
97
|
frontend_origin: Frontend origin URL for CORS headers
|
|
30
98
|
file_type: Either 'raw' (original video) or 'processed' (anonymized video)
|
|
99
|
+
range_header: HTTP Range header value (e.g., "bytes=0-1023") for partial content requests
|
|
31
100
|
|
|
32
101
|
Returns:
|
|
33
|
-
FileResponse: HTTP response streaming the
|
|
102
|
+
FileResponse: HTTP 200 response streaming the entire file (no range header)
|
|
103
|
+
StreamingHttpResponse: HTTP 206 response streaming partial content (with range header)
|
|
34
104
|
|
|
35
105
|
Raises:
|
|
36
106
|
Http404: If video file not found or cannot be accessed
|
|
37
107
|
|
|
38
108
|
Note:
|
|
39
109
|
Permissions are handled by the calling view, not in this helper function.
|
|
110
|
+
HTTP 206 Partial Content support is critical for video seeking in browsers.
|
|
40
111
|
"""
|
|
41
112
|
try:
|
|
42
113
|
# Determine which file to stream based on file_type
|
|
43
114
|
if file_type == 'raw':
|
|
44
|
-
if hasattr(vf, 'active_raw_file') and vf.active_raw_file and hasattr(vf.active_raw_file, '
|
|
45
|
-
|
|
46
|
-
path = Path(vf.active_raw_file.path)
|
|
47
|
-
except (ValueError, AttributeError) as exc:
|
|
48
|
-
raise Http404("No raw file associated with this video") from exc
|
|
115
|
+
if hasattr(vf, 'active_raw_file') and vf.active_raw_file and hasattr(vf.active_raw_file, 'name'):
|
|
116
|
+
file_ref = vf.active_raw_file
|
|
49
117
|
else:
|
|
50
118
|
raise Http404("No raw video file available for this entry")
|
|
51
119
|
|
|
52
120
|
elif file_type == 'processed':
|
|
53
|
-
if hasattr(vf, 'processed_file') and vf.processed_file and hasattr(vf.processed_file, '
|
|
54
|
-
|
|
55
|
-
path = Path(vf.processed_file.path)
|
|
56
|
-
except (ValueError, AttributeError) as exc:
|
|
57
|
-
raise Http404("No processed file associated with this video") from exc
|
|
121
|
+
if hasattr(vf, 'processed_file') and vf.processed_file and hasattr(vf.processed_file, 'name'):
|
|
122
|
+
file_ref = vf.processed_file
|
|
58
123
|
else:
|
|
59
124
|
raise Http404("No processed video file available for this entry")
|
|
60
125
|
else:
|
|
61
126
|
raise ValueError(f"Invalid file_type: {file_type}. Must be 'raw' or 'processed'.")
|
|
62
127
|
|
|
128
|
+
# FIX: Handle both relative and absolute paths
|
|
129
|
+
# Django FileField.path returns .name if MEDIA_ROOT is not set
|
|
130
|
+
# Import services store relative paths like "videos/UUID.mp4"
|
|
131
|
+
# We need to resolve to absolute path: STORAGE_DIR / "videos/UUID.mp4"
|
|
132
|
+
file_name = file_ref.name
|
|
133
|
+
|
|
134
|
+
if file_name.startswith('/'):
|
|
135
|
+
# Already absolute path
|
|
136
|
+
path = Path(file_name)
|
|
137
|
+
else:
|
|
138
|
+
# Relative path - make absolute by prepending STORAGE_DIR
|
|
139
|
+
path = STORAGE_DIR / file_name
|
|
140
|
+
logger.debug("Resolved relative path '%s' to absolute: %s", file_name, path)
|
|
141
|
+
|
|
63
142
|
# Validate file exists on disk
|
|
64
143
|
if not path.exists():
|
|
65
144
|
raise Http404(f"Video file not found on disk: {path}")
|
|
@@ -76,28 +155,59 @@ def _stream_video_file(vf: VideoFile, frontend_origin: str, file_type: str = 'ra
|
|
|
76
155
|
mime, _ = mimetypes.guess_type(str(path))
|
|
77
156
|
content_type = mime or 'video/mp4' # Default to mp4 if detection fails
|
|
78
157
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
158
|
+
# ✅ NEW: HTTP Range Request support for video seeking
|
|
159
|
+
if range_header:
|
|
160
|
+
try:
|
|
161
|
+
# Parse Range header
|
|
162
|
+
start, end = parse_range_header(range_header, file_size)
|
|
163
|
+
logger.debug("Range request: bytes=%d-%d (total: %d)", start, end, file_size)
|
|
164
|
+
|
|
165
|
+
# Stream partial content (HTTP 206)
|
|
166
|
+
response = StreamingHttpResponse(
|
|
167
|
+
stream_file_chunk(path, start, end),
|
|
168
|
+
status=206, # Partial Content
|
|
169
|
+
content_type=content_type
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Set Range-specific headers
|
|
173
|
+
response['Content-Range'] = f'bytes {start}-{end}/{file_size}'
|
|
174
|
+
response['Content-Length'] = str(end - start + 1)
|
|
175
|
+
response['Accept-Ranges'] = 'bytes'
|
|
176
|
+
response['Content-Disposition'] = f'inline; filename="{path.name}"'
|
|
177
|
+
|
|
178
|
+
except ValueError as e:
|
|
179
|
+
# Invalid range header - return 416 Range Not Satisfiable
|
|
180
|
+
logger.warning("Invalid Range header: %s", str(e))
|
|
181
|
+
response = StreamingHttpResponse(
|
|
182
|
+
status=416, # Range Not Satisfiable
|
|
183
|
+
content_type=content_type
|
|
184
|
+
)
|
|
185
|
+
response['Content-Range'] = f'bytes */{file_size}'
|
|
186
|
+
|
|
187
|
+
else:
|
|
188
|
+
# No Range header - stream entire file (HTTP 200)
|
|
189
|
+
try:
|
|
190
|
+
# Open file in binary mode - FileResponse will handle closing
|
|
191
|
+
file_handle = open(path, 'rb')
|
|
192
|
+
response = FileResponse(file_handle, content_type=content_type)
|
|
193
|
+
|
|
194
|
+
# Set HTTP headers for video streaming
|
|
195
|
+
response['Content-Length'] = str(file_size)
|
|
196
|
+
response['Accept-Ranges'] = 'bytes' # Enable HTTP range requests for seeking
|
|
197
|
+
response['Content-Disposition'] = f'inline; filename="{path.name}"'
|
|
198
|
+
|
|
199
|
+
except IOError as e:
|
|
200
|
+
raise Http404(f"Cannot open video file: {str(e)}")
|
|
201
|
+
|
|
202
|
+
# CORS headers for frontend access (both HTTP 200 and 206)
|
|
203
|
+
response["Access-Control-Allow-Origin"] = frontend_origin
|
|
204
|
+
response["Access-Control-Allow-Credentials"] = "true"
|
|
205
|
+
|
|
206
|
+
return response
|
|
97
207
|
|
|
98
208
|
except Exception as e:
|
|
99
209
|
# Log unexpected errors but don't expose internal details
|
|
100
|
-
logger.error(
|
|
210
|
+
logger.error("Unexpected error in _stream_video_file: %s", str(e))
|
|
101
211
|
raise Http404("Video file cannot be streamed")
|
|
102
212
|
|
|
103
213
|
|
|
@@ -127,14 +237,17 @@ class VideoStreamView(APIView):
|
|
|
127
237
|
|
|
128
238
|
def get(self, request, pk=None):
|
|
129
239
|
"""
|
|
130
|
-
Stream raw or anonymized video file with HTTP
|
|
240
|
+
Stream raw or anonymized video file with HTTP Range Request and CORS support.
|
|
241
|
+
|
|
242
|
+
Supports HTTP 206 Partial Content for video seeking functionality.
|
|
131
243
|
|
|
132
244
|
Args:
|
|
133
245
|
request: HTTP request object
|
|
134
246
|
pk: Video ID (primary key)
|
|
135
247
|
|
|
136
248
|
Returns:
|
|
137
|
-
FileResponse:
|
|
249
|
+
FileResponse: HTTP 200 streaming entire video file (no range header)
|
|
250
|
+
StreamingHttpResponse: HTTP 206 streaming partial content (with range header)
|
|
138
251
|
|
|
139
252
|
Raises:
|
|
140
253
|
Http404: If video not found or file cannot be accessed
|
|
@@ -142,6 +255,9 @@ class VideoStreamView(APIView):
|
|
|
142
255
|
if pk is None:
|
|
143
256
|
raise Http404("Video ID is required")
|
|
144
257
|
|
|
258
|
+
# Initialize variables in outer scope
|
|
259
|
+
video_id_int = None
|
|
260
|
+
|
|
145
261
|
try:
|
|
146
262
|
# Validate video_id is numeric
|
|
147
263
|
try:
|
|
@@ -151,19 +267,18 @@ class VideoStreamView(APIView):
|
|
|
151
267
|
|
|
152
268
|
# Support both 'type' (frontend standard) and 'file_type' (legacy)
|
|
153
269
|
# Priority: type > file_type > default 'raw'
|
|
270
|
+
file_type = 'raw' # Default value
|
|
154
271
|
try:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
logger.warning(f"Invalid file_type '{file_type}', defaulting to 'raw'")
|
|
163
|
-
file_type = 'raw'
|
|
272
|
+
file_type_param = request.query_params.get('type') or request.query_params.get('file_type')
|
|
273
|
+
if file_type_param:
|
|
274
|
+
file_type = file_type_param.lower()
|
|
275
|
+
|
|
276
|
+
if file_type not in ['raw', 'processed']:
|
|
277
|
+
logger.warning("Invalid file_type '%s', defaulting to 'raw'", file_type)
|
|
278
|
+
file_type = 'raw'
|
|
164
279
|
|
|
165
280
|
except Exception as e:
|
|
166
|
-
logger.warning(
|
|
281
|
+
logger.warning("Error parsing file_type parameter: %s, defaulting to 'raw'", e)
|
|
167
282
|
file_type = 'raw'
|
|
168
283
|
|
|
169
284
|
# Fetch video from database
|
|
@@ -172,8 +287,11 @@ class VideoStreamView(APIView):
|
|
|
172
287
|
# Get frontend origin for CORS
|
|
173
288
|
frontend_origin = os.environ.get('FRONTEND_ORIGIN', 'http://localhost:8000')
|
|
174
289
|
|
|
175
|
-
#
|
|
176
|
-
|
|
290
|
+
# ✅ NEW: Extract Range header for HTTP 206 support
|
|
291
|
+
range_header = request.META.get('HTTP_RANGE')
|
|
292
|
+
|
|
293
|
+
# Stream the video file with optional range support
|
|
294
|
+
return _stream_video_file(vf, frontend_origin, file_type, range_header)
|
|
177
295
|
|
|
178
296
|
except VideoFile.DoesNotExist:
|
|
179
297
|
raise Http404(f"Video with ID {pk} not found")
|
|
@@ -184,5 +302,5 @@ class VideoStreamView(APIView):
|
|
|
184
302
|
|
|
185
303
|
except Exception as e:
|
|
186
304
|
# Log unexpected errors and convert to Http404
|
|
187
|
-
logger.error(
|
|
305
|
+
logger.error("Unexpected error in VideoStreamView for video_id=%s: %s", pk, str(e))
|
|
188
306
|
raise Http404("Video streaming failed")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: endoreg-db
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.2
|
|
4
4
|
Summary: EndoReg Db Django App
|
|
5
5
|
Project-URL: Homepage, https://info.coloreg.de
|
|
6
6
|
Project-URL: Repository, https://github.com/wg-lux/endoreg-db
|
|
@@ -33,7 +33,7 @@ Requires-Dist: gunicorn>=23.0.0
|
|
|
33
33
|
Requires-Dist: icecream>=2.1.4
|
|
34
34
|
Requires-Dist: librosa==0.11.0
|
|
35
35
|
Requires-Dist: llvmlite>=0.44.0
|
|
36
|
-
Requires-Dist: lx-anonymizer[llm,ocr]>=0.8.
|
|
36
|
+
Requires-Dist: lx-anonymizer[llm,ocr]>=0.8.2
|
|
37
37
|
Requires-Dist: moviepy==2.2.1
|
|
38
38
|
Requires-Dist: mypy>=1.16.0
|
|
39
39
|
Requires-Dist: numpy>=2.2.3
|