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.

Files changed (48) hide show
  1. endoreg_db/helpers/download_segmentation_model.py +31 -0
  2. endoreg_db/migrations/0003_add_center_display_name.py +30 -0
  3. endoreg_db/models/administration/center/center.py +7 -1
  4. endoreg_db/models/media/pdf/raw_pdf.py +31 -26
  5. endoreg_db/models/media/video/create_from_file.py +26 -4
  6. endoreg_db/models/media/video/pipe_1.py +13 -1
  7. endoreg_db/models/media/video/video_file.py +36 -13
  8. endoreg_db/models/media/video/video_file_anonymize.py +2 -1
  9. endoreg_db/models/media/video/video_file_frames/_manage_frame_range.py +12 -0
  10. endoreg_db/models/media/video/video_file_io.py +4 -2
  11. endoreg_db/models/metadata/video_meta.py +2 -2
  12. endoreg_db/serializers/anonymization.py +3 -0
  13. endoreg_db/services/pdf_import.py +131 -45
  14. endoreg_db/services/video_import.py +427 -128
  15. endoreg_db/urls/__init__.py +0 -2
  16. endoreg_db/urls/media.py +201 -4
  17. endoreg_db/urls/report.py +0 -30
  18. endoreg_db/urls/sensitive_meta.py +0 -36
  19. endoreg_db/urls/video.py +30 -88
  20. endoreg_db/utils/paths.py +2 -10
  21. endoreg_db/utils/video/ffmpeg_wrapper.py +67 -4
  22. endoreg_db/views/anonymization/validate.py +76 -32
  23. endoreg_db/views/media/__init__.py +38 -2
  24. endoreg_db/views/media/pdf_media.py +1 -1
  25. endoreg_db/views/media/segments.py +71 -0
  26. endoreg_db/views/media/sensitive_metadata.py +314 -0
  27. endoreg_db/views/media/video_segments.py +596 -0
  28. endoreg_db/views/pdf/reimport.py +18 -8
  29. endoreg_db/views/video/__init__.py +0 -8
  30. endoreg_db/views/video/correction.py +34 -32
  31. endoreg_db/views/video/reimport.py +15 -12
  32. endoreg_db/views/video/video_stream.py +168 -50
  33. {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.1.dist-info}/METADATA +2 -2
  34. {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.1.dist-info}/RECORD +47 -43
  35. endoreg_db/views/video/media/__init__.py +0 -23
  36. /endoreg_db/{urls/pdf.py → config/__init__.py} +0 -0
  37. /endoreg_db/views/video/{media/task_status.py → task_status.py} +0 -0
  38. /endoreg_db/views/video/{media/video_analyze.py → video_analyze.py} +0 -0
  39. /endoreg_db/views/video/{media/video_apply_mask.py → video_apply_mask.py} +0 -0
  40. /endoreg_db/views/video/{media/video_correction.py → video_correction.py} +0 -0
  41. /endoreg_db/views/video/{media/video_download_processed.py → video_download_processed.py} +0 -0
  42. /endoreg_db/views/video/{media/video_media.py → video_media.py} +0 -0
  43. /endoreg_db/views/video/{media/video_meta.py → video_meta.py} +0 -0
  44. /endoreg_db/views/video/{media/video_processing_history.py → video_processing_history.py} +0 -0
  45. /endoreg_db/views/video/{media/video_remove_frames.py → video_remove_frames.py} +0 -0
  46. /endoreg_db/views/video/{media/video_reprocess.py → video_reprocess.py} +0 -0
  47. {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.1.dist-info}/WHEEL +0 -0
  48. {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(video=video).order_by('start_frame')
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.start_frame
75
- original_end = segment.end_frame
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.start_frame = new_start
103
- segment.end_frame = new_end
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/video-metadata/{id}/
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, id):
144
+ def get(self, request, pk):
143
145
  """Get video metadata by video ID."""
144
- video = get_object_or_404(VideoFile, pk=id)
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/video-processing-history/{id}/
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, id):
184
+ def get(self, request, pk):
183
185
  """Get processing history for a video."""
184
- video = get_object_or_404(VideoFile, pk=id)
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/video-analyze/{id}/
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, id):
225
+ def post(self, request, pk):
224
226
  """Analyze video for sensitive content."""
225
- video = get_object_or_404(VideoFile, pk=id)
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 {id} analyzed: {sensitive_count}/{total_frames} sensitive frames")
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 {id}: {str(e)}", exc_info=True)
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/video-apply-mask/{id}/
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, id):
338
+ def post(self, request, pk):
337
339
  """Apply masking to video."""
338
- video = get_object_or_404(VideoFile, pk=id)
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 {id} masked successfully: {output_path}")
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 {id}: {str(e)}", exc_info=True)
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/video-remove-frames/{id}/
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, id):
470
+ def post(self, request, pk):
469
471
  """Remove frames from video."""
470
- video = get_object_or_404(VideoFile, pk=id)
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 {id} cleaned: removed {len(frames_to_remove)} frames")
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 {id}: {str(e)}", exc_info=True)
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/video-reprocess/{id}/
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, id):
633
+ def post(self, request, pk):
632
634
  """Reprocess video through entire anonymization pipeline."""
633
- video = get_object_or_404(VideoFile, pk=id)
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 {id} reprocessing started")
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 {id}: {str(e)}", exc_info=True)
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, video_id):
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 video_id parameter
28
- if not video_id or not isinstance(video_id, int):
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=video_id)
36
- logger.info(f"Found video {video.uuid} (ID: {video_id}) for re-import")
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 {video_id} not found")
41
+ logger.warning(f"Video with ID {pk} not found")
39
42
  return Response(
40
- {"error": f"Video with ID {video_id} not found."},
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: {video_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": 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": 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": 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": 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 mimetypes
12
+ import re
13
13
  import logging
14
- from django.http import FileResponse, Http404
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 _stream_video_file(vf: VideoFile, frontend_origin: str, file_type: str = 'raw') -> FileResponse:
27
+ def parse_range_header(range_header: str, file_size: int) -> Tuple[int, int]:
24
28
  """
25
- Helper function to stream a video file with proper headers and CORS support.
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 video file
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, 'path'):
45
- try:
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, 'path'):
54
- try:
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
- try:
80
- # Open file in binary mode - FileResponse will handle closing
81
- file_handle = open(path, 'rb')
82
- response = FileResponse(file_handle, content_type=content_type)
83
-
84
- # Set HTTP headers for video streaming
85
- response['Content-Length'] = str(file_size)
86
- response['Accept-Ranges'] = 'bytes' # Enable HTTP range requests for seeking
87
- response['Content-Disposition'] = f'inline; filename="{path.name}"'
88
-
89
- # CORS headers for frontend access
90
- response["Access-Control-Allow-Origin"] = frontend_origin
91
- response["Access-Control-Allow-Credentials"] = "true"
92
-
93
- return response
94
-
95
- except IOError as e:
96
- raise Http404(f"Cannot open video file: {str(e)}")
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(f"Unexpected error in _stream_video_file: {str(e)}")
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 range and CORS support.
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: Streaming video file
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
- file_type: str = (
156
- request.query_params.get('type') or
157
- request.query_params.get('file_type') or
158
- 'raw'
159
- ).lower()
160
-
161
- if file_type not in ['raw', 'processed']:
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(f"Error parsing file_type parameter: {e}, defaulting to 'raw'")
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
- # Stream the video file
176
- return _stream_video_file(vf, frontend_origin, file_type)
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(f"Unexpected error in VideoStreamView for video_id={pk}: {str(e)}")
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