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
@@ -3,29 +3,49 @@ Video import service module.
3
3
 
4
4
  Provides high-level functions for importing and anonymizing video files,
5
5
  combining VideoFile creation with frame-level anonymization.
6
+
7
+ Changelog:
8
+ October 14, 2025: Added file locking mechanism to prevent race conditions
9
+ during concurrent video imports (matches PDF import pattern)
6
10
  """
7
11
  from datetime import date
8
12
  import logging
9
13
  import sys
10
14
  import os
11
15
  import shutil
16
+ import time
17
+ from contextlib import contextmanager
12
18
  from pathlib import Path
13
- from typing import TYPE_CHECKING, Union, Dict, Any, Optional
19
+ from typing import Union, Dict, Any, Optional
14
20
  from django.db import transaction
15
- from django.core.exceptions import FieldError
16
21
  from endoreg_db.models import VideoFile, SensitiveMeta
17
22
  from endoreg_db.utils.paths import STORAGE_DIR, RAW_FRAME_DIR, VIDEO_DIR, ANONYM_VIDEO_DIR
18
23
  import random
19
24
  from lx_anonymizer.ocr import trocr_full_image_ocr
25
+ from endoreg_db.utils.hashs import get_video_hash
26
+ from endoreg_db.models.media.video.video_file_anonymize import _cleanup_raw_assets
27
+
28
+
29
+ # File lock configuration (matches PDF import)
30
+ STALE_LOCK_SECONDS = 6000 # 100 minutes - reclaim locks older than this
31
+ MAX_LOCK_WAIT_SECONDS = 90 # New: wait up to 90s for a non-stale lock to clear before skipping
32
+
33
+ logger = logging.getLogger(__name__)
20
34
 
21
35
 
22
36
  class VideoImportService():
23
37
  """
24
38
  Service for importing and anonymizing video files.
25
39
  Uses a central video instance pattern for cleaner state management.
40
+
41
+ Features (October 14, 2025):
42
+ - File locking to prevent concurrent processing of the same video
43
+ - Stale lock detection and reclamation (600s timeout)
44
+ - Hash-based duplicate detection
45
+ - Graceful fallback processing without lx_anonymizer
26
46
  """
27
47
 
28
- def __init__(self, project_root: Path = None):
48
+ def __init__(self, project_root: Optional[Path] = None):
29
49
 
30
50
  # Set up project root path
31
51
  if project_root:
@@ -42,11 +62,69 @@ class VideoImportService():
42
62
  self.current_video = None
43
63
  self.processing_context: Dict[str, Any] = {}
44
64
 
45
- if TYPE_CHECKING:
46
- from endoreg_db.models import VideoFile
47
-
65
+ self.delete_source = False
66
+
48
67
  self.logger = logging.getLogger(__name__)
49
68
 
69
+ @contextmanager
70
+ def _file_lock(self, path: Path):
71
+ """
72
+ Create a file lock to prevent duplicate processing of the same video.
73
+
74
+ This context manager creates a .lock file alongside the video file.
75
+ If the lock file already exists, it checks if it's stale (older than
76
+ STALE_LOCK_SECONDS) and reclaims it if necessary. If it's not stale,
77
+ we now WAIT (up to MAX_LOCK_WAIT_SECONDS) instead of failing immediately.
78
+ """
79
+ lock_path = Path(str(path) + ".lock")
80
+ fd = None
81
+ try:
82
+ deadline = time.time() + MAX_LOCK_WAIT_SECONDS
83
+ while True:
84
+ try:
85
+ # Atomic create; fail if exists
86
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
87
+ break # acquired
88
+ except FileExistsError:
89
+ # Check for stale lock
90
+ age = None
91
+ try:
92
+ st = os.stat(lock_path)
93
+ age = time.time() - st.st_mtime
94
+ except FileNotFoundError:
95
+ # Race: lock removed between exists and stat; retry acquire in next loop
96
+ age = None
97
+
98
+ if age is not None and age > STALE_LOCK_SECONDS:
99
+ try:
100
+ logger.warning(
101
+ "Stale lock detected for %s (age %.0fs). Reclaiming lock...",
102
+ path, age
103
+ )
104
+ lock_path.unlink()
105
+ except Exception as e:
106
+ logger.warning("Failed to remove stale lock %s: %s", lock_path, e)
107
+ # Loop continues and retries acquire immediately
108
+ continue
109
+
110
+ # Not stale: wait until deadline, then give up gracefully
111
+ if time.time() >= deadline:
112
+ raise ValueError(f"File already being processed: {path}")
113
+ time.sleep(1.0)
114
+
115
+ os.write(fd, b"lock")
116
+ os.close(fd)
117
+ fd = None
118
+ yield
119
+ finally:
120
+ try:
121
+ if fd is not None:
122
+ os.close(fd)
123
+ if lock_path.exists():
124
+ lock_path.unlink()
125
+ except OSError:
126
+ pass
127
+
50
128
  def processed(self) -> bool:
51
129
  """Indicates if the current file has already been processed."""
52
130
  return getattr(self, '_processed', False)
@@ -58,31 +136,25 @@ class VideoImportService():
58
136
  processor_name: str,
59
137
  save_video: bool = True,
60
138
  delete_source: bool = True,
61
- ) -> "VideoFile":
139
+ ) -> "VideoFile|None":
62
140
  """
63
141
  High-level helper that orchestrates the complete video import and anonymization process.
64
142
  Uses the central video instance pattern for improved state management.
65
-
66
- Args:
67
- file_path: Path to the video file to import
68
- center_name: Name of the center to associate with video
69
- processor_name: Name of the processor to associate with video
70
- save_video: Whether to save the video file
71
- delete_source: Whether to delete the source file after import
72
-
73
- Returns:
74
- VideoFile instance after import and anonymization
75
-
76
- Raises:
77
- Exception: On any failure during import or anonymization
78
143
  """
79
144
  try:
80
145
  # Initialize processing context
81
146
  self._initialize_processing_context(file_path, center_name, processor_name,
82
147
  save_video, delete_source)
83
148
 
84
- # Validate and prepare file
85
- self._validate_and_prepare_file()
149
+ # Validate and prepare file (may raise ValueError if another worker holds a non-stale lock)
150
+ try:
151
+ self._validate_and_prepare_file()
152
+ except ValueError as ve:
153
+ # Relaxed behavior: if another process is working on this file, skip cleanly
154
+ if "already being processed" in str(ve):
155
+ self.logger.info(f"Skipping {file_path}: {ve}")
156
+ return None
157
+ raise
86
158
 
87
159
  # Create or retrieve video instance
88
160
  self._create_or_retrieve_video_instance()
@@ -126,24 +198,40 @@ class VideoImportService():
126
198
  self.logger.info(f"Initialized processing context for: {file_path}")
127
199
 
128
200
  def _validate_and_prepare_file(self):
129
- """Validate the video file and prepare for processing."""
201
+ """
202
+ Validate the video file and prepare for processing.
203
+
204
+ Uses file locking to prevent concurrent processing of the same video file.
205
+ This prevents race conditions where multiple workers might try to process
206
+ the same video simultaneously.
207
+
208
+ The lock is acquired here and held for the entire import process.
209
+ See _file_lock() for lock reclamation logic.
210
+ """
130
211
  file_path = self.processing_context['file_path']
131
212
 
132
- # Check if already processed
213
+ # Acquire file lock to prevent concurrent processing
214
+ # Lock will be held until finally block in import_and_anonymize()
215
+ self.processing_context['_lock_context'] = self._file_lock(file_path)
216
+ self.processing_context['_lock_context'].__enter__()
217
+
218
+ self.logger.info("Acquired file lock for: %s", file_path)
219
+
220
+ # Check if already processed (memory-based check)
133
221
  if str(file_path) in self.processed_files:
134
- self.logger.info(f"File {file_path} already processed, skipping")
135
- self.processed = True
222
+ self.logger.info("File %s already processed, skipping", file_path)
223
+ self._processed = True
136
224
  raise ValueError(f"File already processed: {file_path}")
137
225
 
138
226
  # Check file exists
139
227
  if not file_path.exists():
140
228
  raise FileNotFoundError(f"Video file not found: {file_path}")
141
229
 
142
- self.logger.info(f"File validation completed for: {file_path}")
230
+ self.logger.info("File validation completed for: %s", file_path)
143
231
 
144
232
  def _create_or_retrieve_video_instance(self):
145
233
  """Create or retrieve the VideoFile instance and move to final storage."""
146
- from endoreg_db.models import VideoFile
234
+ # Removed duplicate import of VideoFile (already imported at module level)
147
235
 
148
236
  self.logger.info("Creating VideoFile instance...")
149
237
 
@@ -161,7 +249,7 @@ class VideoImportService():
161
249
  # Immediately move to final storage locations
162
250
  self._move_to_final_storage()
163
251
 
164
- self.logger.info(f"Created VideoFile with UUID: {self.current_video.uuid}")
252
+ self.logger.info("Created VideoFile with UUID: %s", self.current_video.uuid)
165
253
 
166
254
  # Get and mark processing state
167
255
  state = VideoFile.get_or_create_state(self.current_video)
@@ -180,41 +268,90 @@ class VideoImportService():
180
268
  from endoreg_db.utils import data_paths
181
269
 
182
270
  source_path = self.processing_context['file_path']
183
-
184
- # Define target directories
185
- videos_dir = data_paths["video"] # /data/videos for raw files
271
+
272
+ videos_dir = data_paths["video"]
186
273
  videos_dir.mkdir(parents=True, exist_ok=True)
187
-
188
- # Create target path for raw video in /data/videos
189
- video_filename = f"{self.current_video.uuid}_{Path(source_path).name}"
190
- raw_target_path = videos_dir / video_filename
191
-
192
- # Move source file to raw video storage
193
- try:
194
- shutil.move(str(source_path), str(raw_target_path))
195
- self.logger.info(f"Moved raw video to: {raw_target_path}")
196
- except Exception as e:
197
- self.logger.error(f"Failed to move video to final storage: {e}")
198
- raise
199
-
200
- # Update the raw_file path in database (relative to storage root)
274
+
275
+ _current_video = self.current_video
276
+ assert _current_video is not None, "Current video instance is None during storage move"
277
+
278
+ stored_raw_path = None
279
+ if hasattr(_current_video, "get_raw_file_path"):
280
+ possible_path = _current_video.get_raw_file_path()
281
+ if possible_path:
282
+ try:
283
+ stored_raw_path = Path(possible_path)
284
+ except (TypeError, ValueError):
285
+ stored_raw_path = None
286
+
287
+ if stored_raw_path:
288
+ try:
289
+ storage_root = data_paths["storage"]
290
+ if stored_raw_path.is_absolute():
291
+ if not stored_raw_path.is_relative_to(storage_root):
292
+ stored_raw_path = None
293
+ else:
294
+ if stored_raw_path.parts and stored_raw_path.parts[0] == videos_dir.name:
295
+ stored_raw_path = storage_root / stored_raw_path
296
+ else:
297
+ stored_raw_path = videos_dir / stored_raw_path.name
298
+ except Exception:
299
+ stored_raw_path = None
300
+
301
+ if stored_raw_path and not stored_raw_path.suffix:
302
+ stored_raw_path = None
303
+
304
+ if not stored_raw_path:
305
+ uuid_str = getattr(_current_video, "uuid", None)
306
+ source_suffix = Path(source_path).suffix or ".mp4"
307
+ filename = f"{uuid_str}{source_suffix}" if uuid_str else Path(source_path).name
308
+ stored_raw_path = videos_dir / filename
309
+
310
+ delete_source = bool(self.processing_context.get('delete_source'))
311
+ stored_raw_path.parent.mkdir(parents=True, exist_ok=True)
312
+
313
+ if not stored_raw_path.exists():
314
+ try:
315
+ if source_path.exists():
316
+ if delete_source:
317
+ shutil.move(str(source_path), str(stored_raw_path))
318
+ self.logger.info("Moved raw video to: %s", stored_raw_path)
319
+ else:
320
+ shutil.copy2(str(source_path), str(stored_raw_path))
321
+ self.logger.info("Copied raw video to: %s", stored_raw_path)
322
+ else:
323
+ raise FileNotFoundError(f"Neither stored raw path nor source path exists for {self.processing_context['file_path']}")
324
+ except Exception as e:
325
+ self.logger.error("Failed to place video in final storage: %s", e)
326
+ raise
327
+ else:
328
+ # If we already have the stored copy, respect delete_source flag without touching assets unnecessarily
329
+ if delete_source and source_path.exists():
330
+ try:
331
+ os.remove(source_path)
332
+ self.logger.info("Removed original source file after storing copy: %s", source_path)
333
+ except OSError as e:
334
+ self.logger.warning("Failed to remove source file %s: %s", source_path, e)
335
+
336
+ # Ensure database path points to stored location (relative to storage root)
201
337
  try:
202
338
  storage_root = data_paths["storage"]
203
- relative_path = raw_target_path.relative_to(storage_root)
204
- self.current_video.raw_file.name = str(relative_path)
205
- self.current_video.save(update_fields=['raw_file'])
206
- self.logger.info(f"Updated raw_file path to: {relative_path}")
339
+ relative_path = Path(stored_raw_path).relative_to(storage_root)
340
+ if _current_video.raw_file.name != str(relative_path):
341
+ _current_video.raw_file.name = str(relative_path)
342
+ _current_video.save(update_fields=['raw_file'])
343
+ self.logger.info("Updated raw_file path to: %s", relative_path)
207
344
  except Exception as e:
208
- self.logger.error(f"Failed to update raw_file path: {e}")
209
- # Fallback to simple relative path
210
- self.current_video.raw_file.name = f"videos/{video_filename}"
211
- self.current_video.save(update_fields=['raw_file'])
212
- self.logger.info(f"Updated raw_file path using fallback: videos/{video_filename}")
213
-
214
-
345
+ self.logger.error("Failed to ensure raw_file path is relative: %s", e)
346
+ fallback_relative = Path("videos") / Path(stored_raw_path).name
347
+ if _current_video.raw_file.name != fallback_relative.as_posix():
348
+ _current_video.raw_file.name = fallback_relative.as_posix()
349
+ _current_video.save(update_fields=['raw_file'])
350
+ self.logger.info("Updated raw_file path using fallback: %s", fallback_relative.as_posix())
351
+
215
352
  # Store paths for later processing
216
- self.processing_context['raw_video_path'] = raw_target_path
217
- self.processing_context['video_filename'] = video_filename
353
+ self.processing_context['raw_video_path'] = Path(stored_raw_path)
354
+ self.processing_context['video_filename'] = Path(stored_raw_path).name
218
355
 
219
356
  def _setup_processing_environment(self):
220
357
  """Setup the processing environment without file movement."""
@@ -260,7 +397,10 @@ class VideoImportService():
260
397
  # Check frame cleaning availability
261
398
  frame_cleaning_available, FrameCleaner, ReportReader = self._ensure_frame_cleaning_available()
262
399
 
263
- if not (frame_cleaning_available and self.current_video.raw_file):
400
+ _current_video = self.current_video
401
+ assert _current_video is not None, "Current video instance is None during frame processing"
402
+
403
+ if not (frame_cleaning_available and _current_video.raw_file):
264
404
  self.logger.warning("Frame cleaning not available or conditions not met, using fallback anonymization.")
265
405
  self._fallback_anonymize_video()
266
406
  return
@@ -271,64 +411,175 @@ class VideoImportService():
271
411
  # Get processor ROI information
272
412
  processor_roi, endoscope_roi = self._get_processor_roi_info()
273
413
 
274
- # Perform frame cleaning
275
- self._perform_frame_cleaning(FrameCleaner, processor_roi, endoscope_roi)
414
+ # Perform frame cleaning with timeout to prevent blocking
415
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
276
416
 
277
- self.processing_context['anonymization_completed'] = True
417
+ with ThreadPoolExecutor(max_workers=1) as executor:
418
+ future = executor.submit(self._perform_frame_cleaning, FrameCleaner, processor_roi, endoscope_roi)
419
+ try:
420
+ # Increased timeout to better accommodate ffmpeg + OCR
421
+ future.result(timeout=300)
422
+ self.processing_context['anonymization_completed'] = True
423
+ self.logger.info("Frame cleaning completed successfully within timeout")
424
+ except FutureTimeoutError:
425
+ self.logger.warning("Frame cleaning timed out; entering grace period check for cleaned output")
426
+ # Grace period: detect if cleaned file appears shortly after timeout
427
+ raw_video_path = self.processing_context.get('raw_video_path')
428
+ video_filename = self.processing_context.get('video_filename', Path(raw_video_path).name if raw_video_path else "video.mp4")
429
+ grace_seconds = 60
430
+ expected_cleaned = self.current_video.processed_file
431
+ found = False
432
+ if expected_cleaned is not None:
433
+ for _ in range(grace_seconds):
434
+ if expected_cleaned.exists():
435
+ self.processing_context['cleaned_video_path'] = expected_cleaned
436
+ self.processing_context['anonymization_completed'] = True
437
+ self.logger.info("Detected cleaned video during grace period: %s", expected_cleaned)
438
+ found = True
439
+ break
440
+ time.sleep(1)
441
+ else:
442
+ self._fallback_anonymize_video()
443
+ if not found:
444
+ raise TimeoutError("Frame cleaning operation timed out - likely Ollama connection issue")
278
445
 
279
446
  except Exception as e:
280
- self.logger.warning(f"Frame cleaning failed, continuing with original video: {e}")
281
- self.processing_context['anonymization_completed'] = False
282
- self.processing_context['error_reason'] = f"Frame cleaning failed: {e}"
283
-
447
+ self.logger.warning("Frame cleaning failed (reason: %s), falling back to simple copy", e)
448
+ # Try fallback anonymization when frame cleaning fails
449
+ try:
450
+ self._fallback_anonymize_video()
451
+ except Exception as fallback_error:
452
+ self.logger.error("Fallback anonymization also failed: %s", fallback_error)
453
+ # If even fallback fails, mark as not anonymized but continue import
454
+ self.processing_context['anonymization_completed'] = False
455
+ self.processing_context['error_reason'] = f"Frame cleaning failed: {e}, Fallback failed: {fallback_error}"
456
+
457
+ def _save_anonymized_video(self):
458
+ anonymized_video_path = self.current_video.get_target_anonymized_video_path()
459
+
460
+ if not anonymized_video_path.exists():
461
+ raise RuntimeError(f"Processed video file not found after assembly for {self.current_video.uuid}: {anonymized_video_path}")
462
+
463
+ new_processed_hash = get_video_hash(anonymized_video_path)
464
+ if type(self.current_video).objects.filter(processed_video_hash=new_processed_hash).exclude(pk=self.current_video.pk).exists():
465
+ raise ValueError(f"Processed video hash {new_processed_hash} already exists for another video (Video: {self.current_video.uuid}).")
466
+
467
+ self.current_video.processed_video_hash = new_processed_hash
468
+ self.current_video.processed_file.name = anonymized_video_path.relative_to(STORAGE_DIR).as_posix()
469
+
470
+ update_fields = [
471
+ "processed_video_hash",
472
+ "processed_file",
473
+ "frame_dir",
474
+ ]
475
+
476
+ if self.delete_source:
477
+ original_raw_file_path_to_delete = self.current_video.get_raw_file_path()
478
+ original_raw_frame_dir_to_delete = self.current_video.get_frame_dir_path()
479
+
480
+ self.current_video.raw_file.name = None
481
+
482
+ update_fields.extend(["raw_file", "video_hash"])
483
+
484
+ transaction.on_commit(lambda: _cleanup_raw_assets(
485
+ video_uuid=self.current_video.uuid,
486
+ raw_file_path=original_raw_file_path_to_delete,
487
+ raw_frame_dir=original_raw_frame_dir_to_delete
488
+ ))
489
+
490
+ self.current_video.save(update_fields=update_fields)
491
+ self.current_video.state.mark_anonymized(save=True)
492
+ self.current_video.refresh_from_db()
493
+ return True
284
494
 
285
495
  def _fallback_anonymize_video(self):
286
- """Fallback to create anonymized video if lx_anonymizer is not available."""
496
+ """
497
+ Fallback to create anonymized video if lx_anonymizer is not available.
498
+ """
287
499
  try:
288
- self.logger.info("Attempting to anonymize video using fallback method.")
289
- # This requires sensitive meta to be verified.
290
- if self.current_video.sensitive_meta:
291
- self.current_video.sensitive_meta.is_verified = True
292
- self.current_video.sensitive_meta.save()
293
-
294
- if self.current_video.anonymize_video(delete_original_raw=False):
295
- self.logger.info("Fallback anonymization successful.")
296
- self.processing_context['anonymization_completed'] = True
500
+ self.logger.info("Attempting fallback video anonymization...")
501
+ if self.current_video:
502
+ # Try VideoFile.pipe_2() method if available
503
+ if hasattr(self.current_video, 'pipe_2'):
504
+ self.logger.info("Trying VideoFile.pipe_2() method...")
505
+ if self.current_video.pipe_2():
506
+ self.logger.info("VideoFile.pipe_2() succeeded")
507
+ self.processing_context['anonymization_completed'] = True
508
+ return
509
+ else:
510
+ self.logger.warning("VideoFile.pipe_2() returned False")
511
+ # Try direct anonymization via _anonymize
512
+ if _anonymize(self.current_video, delete_original_raw=self.delete_source):
513
+ self.logger.info("VideoFile._anonymize() succeeded")
514
+ self.processing_context['anonymization_completed'] = True
515
+ return
297
516
  else:
298
- self.logger.warning("Fallback anonymization failed.")
299
- self.processing_context['anonymization_completed'] = False
300
- self.processing_context['error_reason'] = "Fallback anonymization failed"
517
+ self.logger.warning("No VideoFile instance available for fallback anonymization")
518
+
519
+ # Strategy 2: Simple copy (no processing, just copy raw to processed)
520
+ self.logger.info("Using simple copy fallback (raw video will be used as 'processed' video)")
521
+ self.processing_context['anonymization_completed'] = False
522
+ self.processing_context['use_raw_as_processed'] = True
523
+ self.logger.warning("Fallback: Video will be imported without anonymization (raw copy used)")
301
524
  except Exception as e:
302
525
  self.logger.error(f"Error during fallback anonymization: {e}", exc_info=True)
303
526
  self.processing_context['anonymization_completed'] = False
304
- self.processing_context['error_reason'] = f"Fallback anonymization failed: {e}"
305
-
527
+ self.processing_context['error_reason']
306
528
  def _finalize_processing(self):
307
529
  """Finalize processing and update video state."""
308
530
  self.logger.info("Updating video processing state...")
309
531
 
310
532
  with transaction.atomic():
311
533
  # Update basic processing states
534
+ # Ensure state exists before accessing it
535
+
536
+ if not self.current_video:
537
+ try:
538
+ self.current_video.refresh_from_db()
539
+ except Exception as e:
540
+ self.logger.error(f"Failed to refresh current_video from DB: {e}")
541
+ if not self.current_video:
542
+ raise RuntimeError("No current video instance available for finalization")
543
+
544
+ if not self.current_video.processed_file:
545
+ self.logger.warning("No processed file available for current video")
546
+ self.current_video.processed_file = None # Ensure field is not None
547
+ self.current_video.mark_sensitive_meta_processed = False
548
+ else:
549
+ self.current_video.mark_sensitive_meta_processed = True
550
+
551
+ state = self.current_video.get_or_create_state()
552
+ if not state:
553
+ raise RuntimeError("Failed to get or create video state")
554
+
312
555
  # Only mark frames as extracted if they were successfully extracted
313
556
  if self.processing_context.get('frames_extracted', False):
314
- self.current_video.state.frames_extracted = True
557
+ state.frames_extracted = True
315
558
  self.logger.info("Marked frames as extracted in state")
316
559
  else:
317
560
  self.logger.warning("Frames were not extracted, not updating state")
318
561
 
319
- self.current_video.state.frames_initialized = True
320
- self.current_video.state.video_meta_extracted = True
321
- self.current_video.state.text_meta_extracted = True
562
+ # Always mark these as true (metadata extraction attempts were made)
563
+ state.frames_initialized = True
564
+ state.video_meta_extracted = True
565
+ state.text_meta_extracted = True
322
566
 
323
- # Mark sensitive meta as processed
324
- self.current_video.state.mark_sensitive_meta_processed(save=False)
325
-
326
- # Update completion status based on anonymization success
327
- if self.processing_context['anonymization_completed']:
328
- self.logger.info(f"Video {self.current_video.uuid} successfully anonymized")
567
+ # FIX: Only mark as processed if anonymization actually completed
568
+ anonymization_completed = self.processing_context.get('anonymization_completed', False)
569
+ if anonymization_completed:
570
+ state.mark_sensitive_meta_processed(save=False)
571
+ self.logger.info("Anonymization completed - marking sensitive meta as processed")
329
572
  else:
330
- self.logger.warning(f"Video {self.current_video.uuid} imported but not anonymized")
573
+ self.logger.warning(
574
+ "Anonymization NOT completed - NOT marking as processed. "
575
+ f"Reason: {self.processing_context.get('error_reason', 'Unknown')}"
576
+ )
577
+ # Explicitly mark as NOT processed
578
+ state.sensitive_meta_processed = False
331
579
 
580
+ # Save all state changes
581
+ state.save()
582
+ self.logger.info("Video processing state updated")
332
583
  # Save all state changes
333
584
  self.current_video.state.save()
334
585
  self.current_video.save()
@@ -361,61 +612,91 @@ class VideoImportService():
361
612
  # Copy raw to processed location (will be moved to anonym_videos)
362
613
  try:
363
614
  shutil.copy2(str(raw_video_path), str(processed_video_path))
364
- self.logger.info(f"Copied raw video for processing: {processed_video_path}")
615
+ self.logger.info("Copied raw video for processing: %s", processed_video_path)
365
616
  except Exception as e:
366
- self.logger.error(f"Failed to copy raw video: {e}")
367
- processed_video_path = raw_video_path # Use original as fallback
617
+ self.logger.error("Failed to copy raw video: %s", e)
618
+ processed_video_path = None # FIXED: Don't use raw as fallback
368
619
 
369
- # Move processed video to anonym_videos
620
+ # Move processed video to anonym_videos ONLY if it exists
370
621
  if processed_video_path and Path(processed_video_path).exists():
371
622
  try:
372
- anonym_video_filename = f"anonym_{self.processing_context.get('video_filename', Path(processed_video_path).name)}"
623
+ # Clean filename: no original filename leakage
624
+ ext = Path(processed_video_path).suffix or ".mp4"
625
+ anonym_video_filename = f"anonym_{self.current_video.uuid}{ext}"
373
626
  anonym_target_path = anonym_videos_dir / anonym_video_filename
374
-
627
+
628
+ # Move processed video to anonym_videos/
375
629
  shutil.move(str(processed_video_path), str(anonym_target_path))
376
- self.logger.info(f"Moved processed video to: {anonym_target_path}")
377
-
378
- storage_root = data_paths["storage"]
379
- relative_path = anonym_target_path.relative_to(storage_root)
380
-
381
- # Update the file field in database (for processed video)
382
- try:
383
- self.current_video.processed_file = str(relative_path)
384
- self.current_video.save(update_fields=['processed_file'])
385
- self.logger.info(f"Updated file path to: {relative_path}")
386
- except FieldError:
387
- self.logger.warning("Field 'processed_file' does not exist on VideoFile, skipping update")
388
- raise FieldError
630
+ self.logger.info("Moved processed video to: %s", anonym_target_path)
631
+
632
+ # Verify the file actually exists before updating database
633
+ if anonym_target_path.exists():
634
+ try:
635
+ storage_root = data_paths["storage"]
636
+ relative_path = anonym_target_path.relative_to(storage_root)
637
+ # Save relative path (e.g. anonym_videos/anonym_<uuid>.mp4)
638
+ self.current_video.processed_file.name = str(relative_path)
639
+ self.current_video.save(update_fields=["processed_file"])
640
+ self.logger.info("Updated processed_file path to: %s", relative_path)
641
+ except Exception as e:
642
+ self.logger.error("Failed to update processed_file path: %s", e)
643
+ # Fallback to simple relative path
644
+ self.current_video.processed_file.name = f"anonym_videos/{anonym_video_filename}"
645
+ self.current_video.save(update_fields=['processed_file'])
646
+ self.logger.info(
647
+ "Updated processed_file path using fallback: %s",
648
+ f"anonym_videos/{anonym_video_filename}",
649
+ )
650
+
651
+ self.processing_context['anonymization_completed'] = True
652
+ else:
653
+ self.logger.warning("Processed video file not found after move: %s", anonym_target_path)
389
654
  except Exception as e:
390
- self.logger.error(f"Failed to move processed video to anonym_videos: {e}")
655
+ self.logger.error("Failed to move processed video to anonym_videos: %s", e)
656
+ else:
657
+ self.logger.warning("No processed video available - processed_file will remain empty")
658
+ # Leave processed_file empty/null - frontend should fall back to raw_file
391
659
 
392
660
  # Cleanup temporary directories
393
661
  try:
394
662
  from endoreg_db.utils.paths import RAW_FRAME_DIR
395
663
  shutil.rmtree(RAW_FRAME_DIR, ignore_errors=True)
396
- self.logger.debug(f"Cleaned up temporary frames directory: {RAW_FRAME_DIR}")
664
+ self.logger.debug("Cleaned up temporary frames directory: %s", RAW_FRAME_DIR)
397
665
  except Exception as e:
398
- self.logger.warning(f"Failed to remove directory {RAW_FRAME_DIR}: {e}")
666
+ self.logger.warning("Failed to remove directory %s: %s", RAW_FRAME_DIR, e)
399
667
 
400
668
  # Handle source file deletion - this should already be moved, but check raw_videos
401
669
  source_path = self.processing_context['file_path']
402
670
  if self.processing_context['delete_source'] and Path(source_path).exists():
403
671
  try:
404
672
  os.remove(source_path)
405
- self.logger.info(f"Removed remaining source file: {source_path}")
673
+ self.logger.info("Removed remaining source file: %s", source_path)
406
674
  except Exception as e:
407
- self.logger.warning(f"Failed to remove source file {source_path}: {e}")
675
+ self.logger.warning("Failed to remove source file %s: %s", source_path, e)
676
+
677
+ # Check if processed video exists and otherwise call anonymize
678
+
679
+ if not self.current_video.processed_file or not Path(self.current_video.processed_file.path).exists():
680
+ self.logger.warning("No processed_file found after cleanup - video will be unprocessed")
681
+ self.current_video.anonymize(delete_original_raw=self.delete_source)
682
+ self.current_video.save(update_fields=['processed_file'])
683
+
684
+
685
+ self.logger.info("Cleanup and archiving completed")
408
686
 
409
- # Mark as processed
687
+
688
+
689
+ # Mark as processed (in-memory tracking)
410
690
  self.processed_files.add(str(self.processing_context['file_path']))
411
691
 
412
692
  # Refresh from database and finalize state
413
693
  with transaction.atomic():
414
694
  self.current_video.refresh_from_db()
415
- if hasattr(self.current_video, 'state'):
695
+ if hasattr(self.current_video, 'state') and self.processing_context.get('anonymization_completed'):
416
696
  self.current_video.state.mark_sensitive_meta_processed(save=True)
697
+
417
698
 
418
- self.logger.info(f"Import and anonymization completed for VideoFile UUID: {self.current_video.uuid}")
699
+ self.logger.info("Import and anonymization completed for VideoFile UUID: %s", self.current_video.uuid)
419
700
  self.logger.info("Raw video stored in: /data/videos")
420
701
  self.logger.info("Processed video stored in: /data/anonym_videos")
421
702
 
@@ -805,13 +1086,13 @@ class VideoImportService():
805
1086
 
806
1087
  # Define default/placeholder values that are safe to overwrite
807
1088
  SAFE_TO_OVERWRITE_VALUES = [
808
- 'Patient', # Default first name
809
- 'Unknown', # Default last name
1089
+ 'Vorname unbekannt', # Default first name
1090
+ 'Nachname unbekannt', # Default last name
810
1091
  date(1990, 1, 1), # Default DOB
811
1092
  None, # Empty values
812
1093
  '', # Empty strings
813
1094
  'N/A', # Placeholder values
814
- 'Unknown Device', # Default device name
1095
+ 'Unbekanntes Gerät', # Default device name
815
1096
  ]
816
1097
 
817
1098
  for meta_key, sm_field in metadata_mapping.items():
@@ -881,12 +1162,30 @@ class VideoImportService():
881
1162
  self.logger.warning(f"Error during cleanup: {e}")
882
1163
 
883
1164
  def _cleanup_processing_context(self):
884
- """Cleanup processing context."""
1165
+ """
1166
+ Cleanup processing context and release file lock.
1167
+
1168
+ This method is always called in the finally block of import_and_anonymize()
1169
+ to ensure the file lock is released even if processing fails.
1170
+ """
885
1171
  try:
886
- # Clean up any temporary processing artifacts
887
- if self.processing_context.get('frames_extracted'):
888
- # Cleanup handled in _cleanup_and_archive
889
- pass
1172
+ # Release file lock if it was acquired
1173
+ lock_context = self.processing_context.get('_lock_context')
1174
+ if lock_context is not None:
1175
+ try:
1176
+ lock_context.__exit__(None, None, None)
1177
+ self.logger.info("Released file lock")
1178
+ except Exception as e:
1179
+ self.logger.warning(f"Error releasing file lock: {e}")
1180
+
1181
+ # Remove file from processed set if processing failed
1182
+ file_path = self.processing_context.get('file_path')
1183
+ if file_path and not self.processing_context.get('anonymization_completed'):
1184
+ file_path_str = str(file_path)
1185
+ if file_path_str in self.processed_files:
1186
+ self.processed_files.remove(file_path_str)
1187
+ self.logger.info(f"Removed {file_path_str} from processed files (failed processing)")
1188
+
890
1189
  except Exception as e:
891
1190
  self.logger.warning(f"Error during context cleanup: {e}")
892
1191
  finally: