endoreg-db 0.8.1__py3-none-any.whl → 0.8.2.1__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/migrations/0003_add_center_display_name.py +30 -0
- endoreg_db/models/administration/center/center.py +7 -1
- endoreg_db/models/media/pdf/raw_pdf.py +31 -26
- endoreg_db/models/media/video/create_from_file.py +26 -4
- endoreg_db/models/media/video/pipe_1.py +13 -1
- endoreg_db/models/media/video/video_file.py +36 -13
- endoreg_db/models/media/video/video_file_anonymize.py +2 -1
- endoreg_db/models/media/video/video_file_frames/_manage_frame_range.py +12 -0
- endoreg_db/models/media/video/video_file_io.py +4 -2
- endoreg_db/models/metadata/video_meta.py +2 -2
- endoreg_db/serializers/anonymization.py +3 -0
- endoreg_db/services/pdf_import.py +131 -45
- endoreg_db/services/video_import.py +427 -128
- endoreg_db/urls/__init__.py +0 -2
- endoreg_db/urls/media.py +201 -4
- endoreg_db/urls/report.py +0 -30
- endoreg_db/urls/sensitive_meta.py +0 -36
- endoreg_db/urls/video.py +30 -88
- endoreg_db/utils/paths.py +2 -10
- endoreg_db/utils/video/ffmpeg_wrapper.py +67 -4
- endoreg_db/views/anonymization/validate.py +76 -32
- 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 +34 -32
- 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.1.dist-info}/METADATA +2 -2
- {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.1.dist-info}/RECORD +47 -43
- endoreg_db/views/video/media/__init__.py +0 -23
- /endoreg_db/{urls/pdf.py → config/__init__.py} +0 -0
- /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.1.dist-info}/WHEEL +0 -0
- {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -64,15 +64,17 @@ def update_segments_after_frame_removal(video: VideoFile, removed_frames: list)
|
|
|
64
64
|
return {'segments_updated': 0, 'segments_deleted': 0, 'segments_unchanged': 0}
|
|
65
65
|
|
|
66
66
|
removed_frames = sorted(set(removed_frames)) # Ensure sorted and unique
|
|
67
|
-
segments = LabelVideoSegment.objects.filter(
|
|
67
|
+
segments = LabelVideoSegment.objects.filter(
|
|
68
|
+
video_file=video
|
|
69
|
+
).order_by('start_frame_number')
|
|
68
70
|
|
|
69
71
|
segments_updated = 0
|
|
70
72
|
segments_deleted = 0
|
|
71
73
|
segments_unchanged = 0
|
|
72
74
|
|
|
73
75
|
for segment in segments:
|
|
74
|
-
original_start = segment.
|
|
75
|
-
original_end = segment.
|
|
76
|
+
original_start = segment.start_frame_number
|
|
77
|
+
original_end = segment.end_frame_number
|
|
76
78
|
|
|
77
79
|
# Count frames removed before this segment
|
|
78
80
|
frames_before = sum(1 for f in removed_frames if f < original_start)
|
|
@@ -99,9 +101,9 @@ def update_segments_after_frame_removal(video: VideoFile, removed_frames: list)
|
|
|
99
101
|
f"{original_start}-{original_end} → {new_start}-{new_end} "
|
|
100
102
|
f"(before: {frames_before}, within: {frames_within})"
|
|
101
103
|
)
|
|
102
|
-
segment.
|
|
103
|
-
segment.
|
|
104
|
-
segment.save()
|
|
104
|
+
segment.start_frame_number = new_start
|
|
105
|
+
segment.end_frame_number = new_end
|
|
106
|
+
segment.save(update_fields=["start_frame_number", "end_frame_number"])
|
|
105
107
|
segments_updated += 1
|
|
106
108
|
else:
|
|
107
109
|
# No change needed
|
|
@@ -121,7 +123,7 @@ def update_segments_after_frame_removal(video: VideoFile, removed_frames: list)
|
|
|
121
123
|
|
|
122
124
|
class VideoMetadataView(APIView):
|
|
123
125
|
"""
|
|
124
|
-
GET /api/
|
|
126
|
+
GET /api/media/videos/{pk}/metadata/
|
|
125
127
|
|
|
126
128
|
Retrieve analysis results for a video.
|
|
127
129
|
|
|
@@ -139,9 +141,9 @@ class VideoMetadataView(APIView):
|
|
|
139
141
|
}
|
|
140
142
|
"""
|
|
141
143
|
|
|
142
|
-
def get(self, request,
|
|
144
|
+
def get(self, request, pk):
|
|
143
145
|
"""Get video metadata by video ID."""
|
|
144
|
-
video = get_object_or_404(VideoFile, pk=
|
|
146
|
+
video = get_object_or_404(VideoFile, pk=pk)
|
|
145
147
|
|
|
146
148
|
# Get or create metadata record
|
|
147
149
|
metadata, created = VideoMetadata.objects.get_or_create(video=video)
|
|
@@ -152,7 +154,7 @@ class VideoMetadataView(APIView):
|
|
|
152
154
|
|
|
153
155
|
class VideoProcessingHistoryView(APIView):
|
|
154
156
|
"""
|
|
155
|
-
GET /api/
|
|
157
|
+
GET /api/media/videos/{pk}/processing-history/
|
|
156
158
|
|
|
157
159
|
Retrieve processing history for a video.
|
|
158
160
|
|
|
@@ -179,9 +181,9 @@ class VideoProcessingHistoryView(APIView):
|
|
|
179
181
|
]
|
|
180
182
|
"""
|
|
181
183
|
|
|
182
|
-
def get(self, request,
|
|
184
|
+
def get(self, request, pk):
|
|
183
185
|
"""Get processing history for a video."""
|
|
184
|
-
video = get_object_or_404(VideoFile, pk=
|
|
186
|
+
video = get_object_or_404(VideoFile, pk=pk)
|
|
185
187
|
|
|
186
188
|
# Get all history records, newest first
|
|
187
189
|
history = VideoProcessingHistory.objects.filter(
|
|
@@ -198,7 +200,7 @@ class VideoProcessingHistoryView(APIView):
|
|
|
198
200
|
|
|
199
201
|
class VideoAnalyzeView(APIView):
|
|
200
202
|
"""
|
|
201
|
-
POST /api/
|
|
203
|
+
POST /api/media/videos/{pk}/analyze/
|
|
202
204
|
|
|
203
205
|
Analyze video for sensitive frames using MiniCPM-o 2.6 or OCR+LLM.
|
|
204
206
|
|
|
@@ -220,9 +222,9 @@ class VideoAnalyzeView(APIView):
|
|
|
220
222
|
}
|
|
221
223
|
"""
|
|
222
224
|
|
|
223
|
-
def post(self, request,
|
|
225
|
+
def post(self, request, pk):
|
|
224
226
|
"""Analyze video for sensitive content."""
|
|
225
|
-
video = get_object_or_404(VideoFile, pk=
|
|
227
|
+
video = get_object_or_404(VideoFile, pk=pk)
|
|
226
228
|
|
|
227
229
|
# Extract parameters
|
|
228
230
|
detection_method = request.data.get('detection_method', 'minicpm')
|
|
@@ -270,7 +272,7 @@ class VideoAnalyzeView(APIView):
|
|
|
270
272
|
details=f"Found {sensitive_count} sensitive frames out of {total_frames} total frames"
|
|
271
273
|
)
|
|
272
274
|
|
|
273
|
-
logger.info(f"Video {
|
|
275
|
+
logger.info(f"Video {pk} analyzed: {sensitive_count}/{total_frames} sensitive frames")
|
|
274
276
|
|
|
275
277
|
return Response({
|
|
276
278
|
'sensitive_frame_count': sensitive_count,
|
|
@@ -283,7 +285,7 @@ class VideoAnalyzeView(APIView):
|
|
|
283
285
|
})
|
|
284
286
|
|
|
285
287
|
except Exception as e:
|
|
286
|
-
logger.error(f"Video analysis failed for {
|
|
288
|
+
logger.error(f"Video analysis failed for {pk}: {str(e)}", exc_info=True)
|
|
287
289
|
|
|
288
290
|
# Create failure record
|
|
289
291
|
VideoProcessingHistory.objects.create(
|
|
@@ -305,7 +307,7 @@ class VideoAnalyzeView(APIView):
|
|
|
305
307
|
|
|
306
308
|
class VideoApplyMaskView(APIView):
|
|
307
309
|
"""
|
|
308
|
-
POST /api/
|
|
310
|
+
POST /api/media/videos/{pk}/apply-mask/
|
|
309
311
|
|
|
310
312
|
Apply device mask or custom ROI mask to video.
|
|
311
313
|
|
|
@@ -333,9 +335,9 @@ class VideoApplyMaskView(APIView):
|
|
|
333
335
|
Note: Currently synchronous. Will be converted to Celery task in Phase 1.2.
|
|
334
336
|
"""
|
|
335
337
|
|
|
336
|
-
def post(self, request,
|
|
338
|
+
def post(self, request, pk):
|
|
337
339
|
"""Apply masking to video."""
|
|
338
|
-
video = get_object_or_404(VideoFile, pk=
|
|
340
|
+
video = get_object_or_404(VideoFile, pk=pk)
|
|
339
341
|
|
|
340
342
|
# Extract parameters
|
|
341
343
|
mask_type = request.data.get('mask_type', 'device')
|
|
@@ -414,7 +416,7 @@ class VideoApplyMaskView(APIView):
|
|
|
414
416
|
details=f"Masking completed in {processing_time:.1f}s"
|
|
415
417
|
)
|
|
416
418
|
|
|
417
|
-
logger.info(f"Video {
|
|
419
|
+
logger.info(f"Video {pk} masked successfully: {output_path}")
|
|
418
420
|
|
|
419
421
|
return Response({
|
|
420
422
|
'task_id': None, # Will be Celery task ID in Phase 1.2
|
|
@@ -426,7 +428,7 @@ class VideoApplyMaskView(APIView):
|
|
|
426
428
|
raise Exception("Masking failed - check FFmpeg logs")
|
|
427
429
|
|
|
428
430
|
except Exception as e:
|
|
429
|
-
logger.error(f"Video masking failed for {
|
|
431
|
+
logger.error(f"Video masking failed for {pk}: {str(e)}", exc_info=True)
|
|
430
432
|
|
|
431
433
|
history.mark_failure(str(e))
|
|
432
434
|
|
|
@@ -438,7 +440,7 @@ class VideoApplyMaskView(APIView):
|
|
|
438
440
|
|
|
439
441
|
class VideoRemoveFramesView(APIView):
|
|
440
442
|
"""
|
|
441
|
-
POST /api/
|
|
443
|
+
POST /api/media/videos/{pk}/remove-frames/
|
|
442
444
|
|
|
443
445
|
Remove specified frames from video.
|
|
444
446
|
|
|
@@ -465,9 +467,9 @@ class VideoRemoveFramesView(APIView):
|
|
|
465
467
|
Note: Currently synchronous. Will be converted to Celery task in Phase 1.2.
|
|
466
468
|
"""
|
|
467
469
|
|
|
468
|
-
def post(self, request,
|
|
470
|
+
def post(self, request, pk):
|
|
469
471
|
"""Remove frames from video."""
|
|
470
|
-
video = get_object_or_404(VideoFile, pk=
|
|
472
|
+
video = get_object_or_404(VideoFile, pk=pk)
|
|
471
473
|
|
|
472
474
|
# Extract parameters
|
|
473
475
|
frame_list = request.data.get('frame_list')
|
|
@@ -571,7 +573,7 @@ class VideoRemoveFramesView(APIView):
|
|
|
571
573
|
details="; ".join(details_parts)
|
|
572
574
|
)
|
|
573
575
|
|
|
574
|
-
logger.info(f"Video {
|
|
576
|
+
logger.info(f"Video {pk} cleaned: removed {len(frames_to_remove)} frames")
|
|
575
577
|
|
|
576
578
|
return Response({
|
|
577
579
|
'task_id': None, # Will be Celery task ID in Phase 1.2
|
|
@@ -585,7 +587,7 @@ class VideoRemoveFramesView(APIView):
|
|
|
585
587
|
raise Exception("Frame removal failed - check FFmpeg logs")
|
|
586
588
|
|
|
587
589
|
except Exception as e:
|
|
588
|
-
logger.error(f"Frame removal failed for {
|
|
590
|
+
logger.error(f"Frame removal failed for {pk}: {str(e)}", exc_info=True)
|
|
589
591
|
|
|
590
592
|
history.mark_failure(str(e))
|
|
591
593
|
|
|
@@ -613,7 +615,7 @@ class VideoRemoveFramesView(APIView):
|
|
|
613
615
|
|
|
614
616
|
class VideoReprocessView(APIView):
|
|
615
617
|
"""
|
|
616
|
-
POST /api/
|
|
618
|
+
POST /api/media/videos/{pk}/reprocess/
|
|
617
619
|
|
|
618
620
|
Re-run entire anonymization pipeline for a video.
|
|
619
621
|
|
|
@@ -628,9 +630,9 @@ class VideoReprocessView(APIView):
|
|
|
628
630
|
Note: This resets VideoState and triggers video_import service.
|
|
629
631
|
"""
|
|
630
632
|
|
|
631
|
-
def post(self, request,
|
|
633
|
+
def post(self, request, pk):
|
|
632
634
|
"""Reprocess video through entire anonymization pipeline."""
|
|
633
|
-
video = get_object_or_404(VideoFile, pk=
|
|
635
|
+
video = get_object_or_404(VideoFile, pk=pk)
|
|
634
636
|
|
|
635
637
|
try:
|
|
636
638
|
# Create processing history record
|
|
@@ -656,7 +658,7 @@ class VideoReprocessView(APIView):
|
|
|
656
658
|
|
|
657
659
|
history.mark_success(details="Reprocessing initiated")
|
|
658
660
|
|
|
659
|
-
logger.info(f"Video {
|
|
661
|
+
logger.info(f"Video {pk} reprocessing started")
|
|
660
662
|
|
|
661
663
|
return Response({
|
|
662
664
|
'message': 'Reprocessing started',
|
|
@@ -664,7 +666,7 @@ class VideoReprocessView(APIView):
|
|
|
664
666
|
})
|
|
665
667
|
|
|
666
668
|
except Exception as e:
|
|
667
|
-
logger.error(f"Reprocessing failed for {
|
|
669
|
+
logger.error(f"Reprocessing failed for {pk}: {str(e)}", exc_info=True)
|
|
668
670
|
|
|
669
671
|
return Response(
|
|
670
672
|
{'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.1
|
|
3
|
+
Version: 0.8.2.1
|
|
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.1
|
|
36
|
+
Requires-Dist: lx-anonymizer[llm,ocr]>=0.8.2.1
|
|
37
37
|
Requires-Dist: moviepy==2.2.1
|
|
38
38
|
Requires-Dist: mypy>=1.16.0
|
|
39
39
|
Requires-Dist: numpy>=2.2.3
|