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.

Files changed (37) hide show
  1. endoreg_db/helpers/download_segmentation_model.py +31 -0
  2. endoreg_db/models/media/video/pipe_1.py +13 -1
  3. endoreg_db/serializers/anonymization.py +3 -0
  4. endoreg_db/services/pdf_import.py +0 -30
  5. endoreg_db/services/video_import.py +301 -98
  6. endoreg_db/urls/__init__.py +0 -2
  7. endoreg_db/urls/media.py +201 -4
  8. endoreg_db/urls/report.py +0 -30
  9. endoreg_db/urls/video.py +30 -88
  10. endoreg_db/views/anonymization/validate.py +5 -2
  11. endoreg_db/views/media/__init__.py +38 -2
  12. endoreg_db/views/media/pdf_media.py +1 -1
  13. endoreg_db/views/media/segments.py +71 -0
  14. endoreg_db/views/media/sensitive_metadata.py +314 -0
  15. endoreg_db/views/media/video_segments.py +596 -0
  16. endoreg_db/views/pdf/reimport.py +18 -8
  17. endoreg_db/views/video/__init__.py +0 -8
  18. endoreg_db/views/video/correction.py +26 -26
  19. endoreg_db/views/video/reimport.py +15 -12
  20. endoreg_db/views/video/video_stream.py +168 -50
  21. {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.dist-info}/METADATA +2 -2
  22. {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.dist-info}/RECORD +34 -33
  23. endoreg_db/urls/pdf.py +0 -0
  24. endoreg_db/urls/sensitive_meta.py +0 -36
  25. endoreg_db/views/video/media/__init__.py +0 -23
  26. /endoreg_db/views/video/{media/task_status.py → task_status.py} +0 -0
  27. /endoreg_db/views/video/{media/video_analyze.py → video_analyze.py} +0 -0
  28. /endoreg_db/views/video/{media/video_apply_mask.py → video_apply_mask.py} +0 -0
  29. /endoreg_db/views/video/{media/video_correction.py → video_correction.py} +0 -0
  30. /endoreg_db/views/video/{media/video_download_processed.py → video_download_processed.py} +0 -0
  31. /endoreg_db/views/video/{media/video_media.py → video_media.py} +0 -0
  32. /endoreg_db/views/video/{media/video_meta.py → video_meta.py} +0 -0
  33. /endoreg_db/views/video/{media/video_processing_history.py → video_processing_history.py} +0 -0
  34. /endoreg_db/views/video/{media/video_remove_frames.py → video_remove_frames.py} +0 -0
  35. /endoreg_db/views/video/{media/video_reprocess.py → video_reprocess.py} +0 -0
  36. {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.dist-info}/WHEEL +0 -0
  37. {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/video-metadata/{id}/
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, id):
142
+ def get(self, request, pk):
143
143
  """Get video metadata by video ID."""
144
- video = get_object_or_404(VideoFile, pk=id)
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/video-processing-history/{id}/
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, id):
182
+ def get(self, request, pk):
183
183
  """Get processing history for a video."""
184
- video = get_object_or_404(VideoFile, pk=id)
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/video-analyze/{id}/
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, id):
223
+ def post(self, request, pk):
224
224
  """Analyze video for sensitive content."""
225
- video = get_object_or_404(VideoFile, pk=id)
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 {id} analyzed: {sensitive_count}/{total_frames} sensitive frames")
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 {id}: {str(e)}", exc_info=True)
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/video-apply-mask/{id}/
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, id):
336
+ def post(self, request, pk):
337
337
  """Apply masking to video."""
338
- video = get_object_or_404(VideoFile, pk=id)
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 {id} masked successfully: {output_path}")
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 {id}: {str(e)}", exc_info=True)
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/video-remove-frames/{id}/
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, id):
468
+ def post(self, request, pk):
469
469
  """Remove frames from video."""
470
- video = get_object_or_404(VideoFile, pk=id)
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 {id} cleaned: removed {len(frames_to_remove)} frames")
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 {id}: {str(e)}", exc_info=True)
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/video-reprocess/{id}/
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, id):
631
+ def post(self, request, pk):
632
632
  """Reprocess video through entire anonymization pipeline."""
633
- video = get_object_or_404(VideoFile, pk=id)
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 {id} reprocessing started")
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 {id}: {str(e)}", exc_info=True)
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, 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
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
37
37
  Requires-Dist: moviepy==2.2.1
38
38
  Requires-Dist: mypy>=1.16.0
39
39
  Requires-Dist: numpy>=2.2.3