endoreg-db 0.8.1__py3-none-any.whl → 0.8.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of endoreg-db might be problematic. Click here for more details.
- endoreg_db/helpers/download_segmentation_model.py +31 -0
- endoreg_db/migrations/0003_add_center_display_name.py +30 -0
- endoreg_db/models/administration/center/center.py +7 -1
- endoreg_db/models/media/pdf/raw_pdf.py +31 -26
- endoreg_db/models/media/video/create_from_file.py +26 -4
- endoreg_db/models/media/video/pipe_1.py +13 -1
- endoreg_db/models/media/video/video_file.py +36 -13
- endoreg_db/models/media/video/video_file_anonymize.py +2 -1
- endoreg_db/models/media/video/video_file_frames/_manage_frame_range.py +12 -0
- endoreg_db/models/media/video/video_file_io.py +4 -2
- endoreg_db/models/metadata/video_meta.py +2 -2
- endoreg_db/serializers/anonymization.py +3 -0
- endoreg_db/services/pdf_import.py +131 -45
- endoreg_db/services/video_import.py +427 -128
- endoreg_db/urls/__init__.py +0 -2
- endoreg_db/urls/media.py +201 -4
- endoreg_db/urls/report.py +0 -30
- endoreg_db/urls/sensitive_meta.py +0 -36
- endoreg_db/urls/video.py +30 -88
- endoreg_db/utils/paths.py +2 -10
- endoreg_db/utils/video/ffmpeg_wrapper.py +67 -4
- endoreg_db/views/anonymization/validate.py +76 -32
- endoreg_db/views/media/__init__.py +38 -2
- endoreg_db/views/media/pdf_media.py +1 -1
- endoreg_db/views/media/segments.py +71 -0
- endoreg_db/views/media/sensitive_metadata.py +314 -0
- endoreg_db/views/media/video_segments.py +596 -0
- endoreg_db/views/pdf/reimport.py +18 -8
- endoreg_db/views/video/__init__.py +0 -8
- endoreg_db/views/video/correction.py +34 -32
- endoreg_db/views/video/reimport.py +15 -12
- endoreg_db/views/video/video_stream.py +168 -50
- {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.1.dist-info}/METADATA +2 -2
- {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.1.dist-info}/RECORD +47 -43
- endoreg_db/views/video/media/__init__.py +0 -23
- /endoreg_db/{urls/pdf.py → config/__init__.py} +0 -0
- /endoreg_db/views/video/{media/task_status.py → task_status.py} +0 -0
- /endoreg_db/views/video/{media/video_analyze.py → video_analyze.py} +0 -0
- /endoreg_db/views/video/{media/video_apply_mask.py → video_apply_mask.py} +0 -0
- /endoreg_db/views/video/{media/video_correction.py → video_correction.py} +0 -0
- /endoreg_db/views/video/{media/video_download_processed.py → video_download_processed.py} +0 -0
- /endoreg_db/views/video/{media/video_media.py → video_media.py} +0 -0
- /endoreg_db/views/video/{media/video_meta.py → video_meta.py} +0 -0
- /endoreg_db/views/video/{media/video_processing_history.py → video_processing_history.py} +0 -0
- /endoreg_db/views/video/{media/video_remove_frames.py → video_remove_frames.py} +0 -0
- /endoreg_db/views/video/{media/video_reprocess.py → video_reprocess.py} +0 -0
- {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.1.dist-info}/WHEEL +0 -0
- {endoreg_db-0.8.1.dist-info → endoreg_db-0.8.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
#
|
|
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(
|
|
135
|
-
self.
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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'] =
|
|
217
|
-
self.processing_context['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
|
-
|
|
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
|
-
|
|
414
|
+
# Perform frame cleaning with timeout to prevent blocking
|
|
415
|
+
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
|
|
276
416
|
|
|
277
|
-
|
|
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(
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
"""
|
|
496
|
+
"""
|
|
497
|
+
Fallback to create anonymized video if lx_anonymizer is not available.
|
|
498
|
+
"""
|
|
287
499
|
try:
|
|
288
|
-
self.logger.info("Attempting
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
self.current_video
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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("
|
|
299
|
-
|
|
300
|
-
|
|
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']
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
#
|
|
324
|
-
self.
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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(
|
|
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(
|
|
615
|
+
self.logger.info("Copied raw video for processing: %s", processed_video_path)
|
|
365
616
|
except Exception as e:
|
|
366
|
-
self.logger.error(
|
|
367
|
-
processed_video_path =
|
|
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
|
-
|
|
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(
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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(
|
|
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(
|
|
664
|
+
self.logger.debug("Cleaned up temporary frames directory: %s", RAW_FRAME_DIR)
|
|
397
665
|
except Exception as e:
|
|
398
|
-
self.logger.warning(
|
|
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(
|
|
673
|
+
self.logger.info("Removed remaining source file: %s", source_path)
|
|
406
674
|
except Exception as e:
|
|
407
|
-
self.logger.warning(
|
|
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
|
-
|
|
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(
|
|
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
|
-
'
|
|
809
|
-
'
|
|
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
|
-
'
|
|
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
|
-
"""
|
|
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
|
-
#
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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:
|