major server manager overhaul
This commit is contained in:
@@ -2,54 +2,96 @@ import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import json
|
||||
import time
|
||||
import itertools
|
||||
from PIL import Image
|
||||
import imagehash
|
||||
from sqlalchemy.orm import Session
|
||||
from .samba_manager import SambaManager
|
||||
from .config import Settings
|
||||
from .stash_service import StashService
|
||||
from . import models
|
||||
import itertools
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class CancellationException(Exception):
|
||||
pass
|
||||
|
||||
class CancellableWriter:
|
||||
def __init__(self, file_obj, check_cancel_func):
|
||||
self.file_obj = file_obj
|
||||
self.check_cancel_func = check_cancel_func
|
||||
|
||||
def write(self, data):
|
||||
if self.check_cancel_func():
|
||||
raise CancellationException("Scan canceled by user")
|
||||
return self.file_obj.write(data)
|
||||
|
||||
def close(self):
|
||||
return self.file_obj.close()
|
||||
|
||||
def flush(self):
|
||||
return self.file_obj.flush()
|
||||
|
||||
def tell(self):
|
||||
return self.file_obj.tell()
|
||||
|
||||
def seek(self, offset, whence=0):
|
||||
return self.file_obj.seek(offset, whence)
|
||||
|
||||
class DuplicatesManager:
|
||||
def __init__(self, settings: Settings, db: Session):
|
||||
self.settings = settings
|
||||
self.db = db
|
||||
self.videos_root = "/videos"
|
||||
self.stash_service = StashService(settings)
|
||||
self.exclusions_file = "resources/config/exclusions.json"
|
||||
self._load_exclusions()
|
||||
|
||||
def _load_exclusions(self):
|
||||
if os.path.exists(self.exclusions_file):
|
||||
try:
|
||||
with open(self.exclusions_file, 'r') as f:
|
||||
self.exclusions = json.load(f)
|
||||
except:
|
||||
self.exclusions = []
|
||||
else:
|
||||
self.exclusions = []
|
||||
|
||||
def _save_exclusions(self):
|
||||
os.makedirs(os.path.dirname(self.exclusions_file), exist_ok=True)
|
||||
with open(self.exclusions_file, 'w') as f:
|
||||
json.dump(self.exclusions, f)
|
||||
|
||||
def add_exclusion(self, path):
|
||||
if path not in self.exclusions:
|
||||
self.exclusions.append(path)
|
||||
self._save_exclusions()
|
||||
|
||||
def remove_exclusion(self, path):
|
||||
if path in self.exclusions:
|
||||
self.exclusions.remove(path)
|
||||
self._save_exclusions()
|
||||
|
||||
def _is_excluded(self, path):
|
||||
for excl in self.exclusions:
|
||||
if path.startswith(excl):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_video_duration(self, filepath):
|
||||
logger.debug(f"Running ffprobe for duration of {filepath}")
|
||||
try:
|
||||
command = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
filepath,
|
||||
"ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1", filepath
|
||||
]
|
||||
logger.debug(f"ffprobe command: {' '.join(command)}")
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
logger.debug(f"ffprobe stdout: {result.stdout.strip()}")
|
||||
logger.debug(f"ffprobe stderr: {result.stderr.strip()}")
|
||||
result = subprocess.run(command, capture_output=True, text=True, check=True)
|
||||
return float(result.stdout)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
except Exception as e:
|
||||
logger.error(f"ffprobe failed for {filepath}: {e}")
|
||||
return None
|
||||
|
||||
def _get_frame_hash(self, filepath):
|
||||
logger.debug(f"Running ffmpeg for frame hash of {filepath}")
|
||||
def _get_frame_hash(self, filepath, algorithm='phash'):
|
||||
tmp_frame_path = ""
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp_frame:
|
||||
@@ -57,264 +99,390 @@ class DuplicatesManager:
|
||||
command = ["ffmpeg"]
|
||||
if self.settings.gpu_enabled:
|
||||
command.extend(["-hwaccel", "cuda"])
|
||||
|
||||
command.extend([
|
||||
"-i",
|
||||
filepath,
|
||||
"-ss",
|
||||
"00:00:10",
|
||||
"-vframes",
|
||||
"1",
|
||||
"-y",
|
||||
tmp_frame_path,
|
||||
])
|
||||
|
||||
logger.debug(f"ffmpeg command: {' '.join(command)}")
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
logger.debug(f"ffmpeg stdout: {result.stdout.strip()}")
|
||||
logger.debug(f"ffmpeg stderr: {result.stderr.strip()}")
|
||||
# Extract frame at 10s or 10%? Fixed 10s for now.
|
||||
command.extend(["-i", filepath, "-ss", "00:00:10", "-vframes", "1", "-y", tmp_frame_path])
|
||||
|
||||
subprocess.run(command, capture_output=True, check=True)
|
||||
|
||||
if os.path.exists(tmp_frame_path):
|
||||
logger.debug(f"Temporary frame file exists: {tmp_frame_path}, size: {os.path.getsize(tmp_frame_path)} bytes")
|
||||
phash = imagehash.phash(Image.open(tmp_frame_path))
|
||||
return str(phash)
|
||||
else:
|
||||
logger.warning(f"Temporary frame file was not created: {tmp_frame_path}")
|
||||
return None
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
logger.error(f"ffmpeg failed for {filepath}: {e}")
|
||||
return None
|
||||
except Image.UnidentifiedImageError as e:
|
||||
logger.error(f"PIL.UnidentifiedImageError for {filepath} with temp file {tmp_frame_path}: {e}")
|
||||
return None
|
||||
if os.path.exists(tmp_frame_path) and os.path.getsize(tmp_frame_path) > 0:
|
||||
img = Image.open(tmp_frame_path)
|
||||
if algorithm == 'ahash': h = imagehash.average_hash(img)
|
||||
elif algorithm == 'dhash': h = imagehash.dhash(img)
|
||||
else: h = imagehash.phash(img)
|
||||
return str(h)
|
||||
except Exception as e:
|
||||
logger.error(f"Hash generation failed for {filepath}: {e}")
|
||||
finally:
|
||||
if os.path.exists(tmp_frame_path):
|
||||
os.remove(tmp_frame_path)
|
||||
return None
|
||||
|
||||
def _is_video_file(self, filename):
|
||||
video_extensions = ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm']
|
||||
return any(filename.lower().endswith(ext) for ext in video_extensions)
|
||||
def generate_contact_sheet(self, video_path):
|
||||
"""
|
||||
Generates a 3x3 contact sheet for the video.
|
||||
Returns (relative_path, phash_str)
|
||||
"""
|
||||
try:
|
||||
duration = self._get_video_duration(video_path)
|
||||
if not duration or duration < 10: return None, None
|
||||
|
||||
# Extract 9 frames at intervals
|
||||
interval = duration / 10
|
||||
timestamps = [interval * i for i in range(1, 10)]
|
||||
|
||||
# We use a temp dir to store frames, then stitch
|
||||
# ffmpeg tile filter is good but seeking is faster for sparse frames on large files?
|
||||
# Actually, `ffmpeg -i ... -vf fps=... tile=...` reads the whole file which is slow over network/SMB.
|
||||
# Best to seek.
|
||||
|
||||
# Since we have the file locally in tmp_path (downloaded), seeking is fast.
|
||||
|
||||
frames = []
|
||||
with tempfile.TemporaryDirectory() as temp_frames_dir:
|
||||
for idx, ts in enumerate(timestamps):
|
||||
out_frame = os.path.join(temp_frames_dir, f"frame_{idx}.jpg")
|
||||
# fast seek
|
||||
subprocess.run(
|
||||
["ffmpeg", "-ss", str(ts), "-i", video_path, "-vframes", "1", "-q:v", "5", "-vf", "scale=320:-1", "-y", out_frame],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False
|
||||
)
|
||||
if os.path.exists(out_frame):
|
||||
frames.append(Image.open(out_frame))
|
||||
|
||||
if len(frames) < 4: return None, None # Need at least some frames
|
||||
|
||||
# Stitch 3x3 (or adaptive)
|
||||
# Create blank image
|
||||
w, h = frames[0].size
|
||||
grid_w = w * 3
|
||||
grid_h = h * 3
|
||||
contact_sheet = Image.new('RGB', (grid_w, grid_h))
|
||||
|
||||
for idx, frame in enumerate(frames):
|
||||
if idx >= 9: break
|
||||
x = (idx % 3) * w
|
||||
y = (idx // 3) * h
|
||||
contact_sheet.paste(frame, (x, y))
|
||||
|
||||
# Save
|
||||
cache_dir = "resources/cache/thumbnails"
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
# Use hash of path to ensure uniqueness/retrievability
|
||||
filename_hash = imagehash.hex_to_hash(os.path.basename(video_path)) # Just use random or md5
|
||||
import hashlib
|
||||
file_hash = hashlib.md5(video_path.encode()).hexdigest()
|
||||
out_name = f"{file_hash}.jpg"
|
||||
out_path = os.path.join(cache_dir, out_name)
|
||||
|
||||
contact_sheet.save(out_path, "JPEG", quality=80)
|
||||
|
||||
# Calculate Hash of the SHEET
|
||||
sheet_hash = imagehash.phash(contact_sheet)
|
||||
|
||||
return out_name, str(sheet_hash)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Contact sheet generation failed: {e}")
|
||||
return None, None
|
||||
|
||||
def _process_video_file(self, samba_manager, filepath, filename, size, algorithm='phash', scan_type='fast', log_func=None, cancel_check_func=None):
|
||||
if cancel_check_func and cancel_check_func(): return
|
||||
|
||||
if self._is_excluded(filepath):
|
||||
return
|
||||
|
||||
def _process_video_file(self, samba_manager: SambaManager, filepath, filename, size):
|
||||
logger.info(f"Processing video: {filepath} ({filename})")
|
||||
existing_video = self.db.query(models.VideoFile).filter_by(filepath=filepath).first()
|
||||
|
||||
# If in scene mode, check if we already have the scene data
|
||||
if existing_video:
|
||||
if existing_video.size == size:
|
||||
logger.info(f"Skipping already processed and unaltered video: {filepath}")
|
||||
return
|
||||
if scan_type == 'scene' and not existing_video.contact_sheet_path:
|
||||
if log_func: log_func(f"Updating {filename} with contact sheet")
|
||||
# Continue to processing
|
||||
elif existing_video.size == size:
|
||||
return # Skip if unchanged
|
||||
else:
|
||||
logger.info(f"File {filepath} has altered size ({existing_video.size} -> {size}). Re-processing.")
|
||||
self.db.delete(existing_video)
|
||||
self.db.commit()
|
||||
existing_video = None
|
||||
|
||||
if log_func: log_func(f"Processing: {filename} (Mode: {scan_type})")
|
||||
|
||||
# --- Stash Integration ---
|
||||
if self.settings.stash_enabled:
|
||||
if cancel_check_func and cancel_check_func(): return
|
||||
stash_phash, stash_oshash, scene_id, stash_duration = self.stash_service.get_file_metadata(filepath)
|
||||
if stash_phash:
|
||||
if log_func: log_func(f"Found Stash metadata for {filename}")
|
||||
|
||||
sheet_path = None
|
||||
if scan_type == 'scene' and stash_oshash:
|
||||
if cancel_check_func and cancel_check_func(): return
|
||||
remote_sprite = self.stash_service.get_sprite_path(stash_oshash)
|
||||
local_sheet_name = f"stash_{stash_oshash}.jpg"
|
||||
local_sheet_path = os.path.join("resources/cache/thumbnails", local_sheet_name)
|
||||
if not os.path.exists(local_sheet_path):
|
||||
try:
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(local_sheet_path), exist_ok=True)
|
||||
|
||||
# Use stash_share for sprite download
|
||||
stash_samba = samba_manager
|
||||
if samba_manager.share_name != self.settings.stash_share:
|
||||
stash_samba = SambaManager(
|
||||
self.settings.samba_server_ip,
|
||||
self.settings.stash_share,
|
||||
self.settings.samba_username,
|
||||
self.settings.samba_password
|
||||
)
|
||||
|
||||
try:
|
||||
with open(local_sheet_path, "wb") as f:
|
||||
stash_samba.download_file(remote_sprite, f)
|
||||
sheet_path = local_sheet_name
|
||||
finally:
|
||||
if stash_samba != samba_manager:
|
||||
stash_samba.close()
|
||||
except Exception as e:
|
||||
if log_func: log_func(f"Failed to download Stash sprite for {filename}: {e}")
|
||||
else:
|
||||
sheet_path = local_sheet_name
|
||||
|
||||
# Save to DB
|
||||
video = models.VideoFile(
|
||||
filepath=filepath, filename=filename, size=size,
|
||||
duration=stash_duration or 0, phash=stash_phash,
|
||||
contact_sheet_path=sheet_path,
|
||||
scene_phash=None
|
||||
)
|
||||
self.db.add(video)
|
||||
self.db.commit()
|
||||
return
|
||||
# --- End Stash Integration ---
|
||||
|
||||
if cancel_check_func and cancel_check_func(): return
|
||||
|
||||
# Download to temp
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp_video:
|
||||
try:
|
||||
samba_manager.download_file(filepath, tmp_video)
|
||||
if log_func: log_func(f"Downloading {filename} ({size/1024/1024:.2f} MB)...")
|
||||
|
||||
# Wrap for cancellation during download
|
||||
writer = tmp_video
|
||||
if cancel_check_func:
|
||||
writer = CancellableWriter(tmp_video, cancel_check_func)
|
||||
|
||||
samba_manager.download_file(filepath, writer)
|
||||
tmp_path = tmp_video.name
|
||||
except CancellationException:
|
||||
if log_func: log_func(f"Download aborted for {filename}")
|
||||
tmp_video.close()
|
||||
os.remove(tmp_video.name)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download {filepath}: {e}")
|
||||
if log_func: log_func(f"Download failed: {filepath} - {e}")
|
||||
tmp_video.close()
|
||||
os.remove(tmp_video.name)
|
||||
return
|
||||
|
||||
duration = self._get_video_duration(tmp_video.name)
|
||||
phash = self._get_frame_hash(tmp_video.name)
|
||||
|
||||
os.remove(tmp_video.name)
|
||||
|
||||
if duration is not None and phash is not None:
|
||||
video_file = models.VideoFile(
|
||||
filepath=filepath,
|
||||
filename=filename,
|
||||
size=size,
|
||||
duration=duration,
|
||||
phash=phash,
|
||||
)
|
||||
self.db.add(video_file)
|
||||
self.db.commit()
|
||||
logger.info(f"Processed video: {filepath}")
|
||||
else:
|
||||
logger.warning(f"Could not get duration or hash for {filepath}")
|
||||
|
||||
def scan_videos(self, samba_manager: SambaManager):
|
||||
state_file = "logs/duplicates_scan.state"
|
||||
progress_file = "logs/duplicates_scan.progress"
|
||||
pause_file = "logs/duplicates_scan.pause"
|
||||
|
||||
dirs_to_scan = []
|
||||
|
||||
try:
|
||||
if os.path.exists(progress_file):
|
||||
with open(progress_file, 'r') as f:
|
||||
last_processed_dir = f.read().strip()
|
||||
logger.info(f"Resuming scan from last in-progress directory: {last_processed_dir}")
|
||||
dirs_to_scan.append(last_processed_dir)
|
||||
|
||||
if os.path.exists(state_file):
|
||||
with open(state_file, 'r') as f:
|
||||
dirs_to_scan.extend([line.strip() for line in f if line.strip()])
|
||||
logger.info(f"Loaded {len(dirs_to_scan)} directories from state file.")
|
||||
if cancel_check_func and cancel_check_func(): return
|
||||
|
||||
if not dirs_to_scan:
|
||||
dirs_to_scan = [self.videos_root]
|
||||
duration = self._get_video_duration(tmp_path)
|
||||
phash = self._get_frame_hash(tmp_path, algorithm)
|
||||
|
||||
if cancel_check_func and cancel_check_func(): return
|
||||
|
||||
logger.info(f"Starting scan for videos in {self.videos_root} on share 'isolation'")
|
||||
sheet_path = None
|
||||
scene_hash = None
|
||||
|
||||
if scan_type == 'scene':
|
||||
sheet_path, scene_hash = self.generate_contact_sheet(tmp_path)
|
||||
|
||||
if duration is not None: # phash might be None if image generation failed
|
||||
if not existing_video:
|
||||
video = models.VideoFile(
|
||||
filepath=filepath, filename=filename, size=size,
|
||||
duration=duration, phash=phash or "",
|
||||
contact_sheet_path=sheet_path,
|
||||
scene_phash=scene_hash
|
||||
)
|
||||
self.db.add(video)
|
||||
else:
|
||||
# Update existing
|
||||
existing_video.duration = duration
|
||||
existing_video.phash = phash or ""
|
||||
if sheet_path: existing_video.contact_sheet_path = sheet_path
|
||||
if scene_hash: existing_video.scene_phash = scene_hash
|
||||
|
||||
self.db.commit()
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
try: os.remove(tmp_path)
|
||||
except: pass
|
||||
|
||||
def scan_videos(self, samba_manager, root_paths=["/videos"], algorithm='phash', scan_type='fast'):
|
||||
state_file = "logs/duplicates_scan.state"
|
||||
progress_file = "logs/duplicates_scan.json"
|
||||
cancel_file = "logs/duplicates_scan.cancel"
|
||||
log_file = "logs/duplicates_scan.log"
|
||||
|
||||
# Helper to log to file and console
|
||||
def log(msg):
|
||||
try:
|
||||
with open(log_file, "a") as f:
|
||||
f.write(f"{msg}\n")
|
||||
except: pass
|
||||
logger.info(msg)
|
||||
|
||||
is_cancelled = False
|
||||
# Helper to check cancellation
|
||||
def check_cancel():
|
||||
nonlocal is_cancelled
|
||||
if is_cancelled: return True
|
||||
|
||||
if os.path.exists(cancel_file):
|
||||
log("Scan canceled by user.")
|
||||
try:
|
||||
os.remove(cancel_file)
|
||||
except OSError:
|
||||
pass
|
||||
with open(progress_file, 'w') as f:
|
||||
json.dump({"status": "canceled", "processed": processed_files}, f)
|
||||
is_cancelled = True
|
||||
return True
|
||||
return False
|
||||
|
||||
# Clear log file
|
||||
with open(log_file, "w") as f:
|
||||
f.write("Scan started...\n")
|
||||
|
||||
# Ensure cancel file is gone before starting
|
||||
if os.path.exists(cancel_file):
|
||||
try: os.remove(cancel_file)
|
||||
except OSError: pass
|
||||
|
||||
dirs_to_scan = list(root_paths)
|
||||
processed_files = 0
|
||||
|
||||
try:
|
||||
while dirs_to_scan:
|
||||
while os.path.exists(pause_file):
|
||||
logger.info("Scan is paused. Waiting for resume signal...")
|
||||
time.sleep(5)
|
||||
if check_cancel(): return
|
||||
|
||||
current_path = dirs_to_scan.pop(0)
|
||||
|
||||
with open(progress_file, 'w') as f:
|
||||
f.write(current_path)
|
||||
json.dump({"status": "scanning", "current": current_path, "processed": processed_files}, f)
|
||||
|
||||
logger.info(f"Scanning directory: {current_path}")
|
||||
files_and_dirs = samba_manager.list_path(current_path)
|
||||
|
||||
if "error" in files_and_dirs:
|
||||
logger.error(f"Failed to list path {current_path}: {files_and_dirs['error']}")
|
||||
try:
|
||||
log(f"Scanning directory: {current_path}")
|
||||
items = samba_manager.list_path(current_path)
|
||||
except Exception as e:
|
||||
log(f"Error listing {current_path}: {e}")
|
||||
continue
|
||||
|
||||
subdirs = []
|
||||
for item in files_and_dirs:
|
||||
while os.path.exists(pause_file):
|
||||
logger.info("Scan is paused. Waiting for resume signal...")
|
||||
time.sleep(5)
|
||||
for item in items:
|
||||
if check_cancel(): return
|
||||
|
||||
if item["is_directory"]:
|
||||
subdirs.append(item["path"])
|
||||
elif self._is_video_file(item["name"]):
|
||||
self._process_video_file(samba_manager, item["path"], item["name"], item["size"])
|
||||
if item['name'] in ['.', '..']: continue
|
||||
|
||||
if item['is_directory']:
|
||||
if not self._is_excluded(item['path']):
|
||||
dirs_to_scan.append(item['path'])
|
||||
elif any(item['name'].lower().endswith(ext) for ext in ['.mp4', '.mkv', '.avi', '.mov', '.wmv']):
|
||||
self._process_video_file(
|
||||
samba_manager, item['path'], item['name'], item['size'],
|
||||
algorithm, scan_type,
|
||||
log_func=log, cancel_check_func=check_cancel
|
||||
)
|
||||
processed_files += 1
|
||||
|
||||
with open(progress_file, 'w') as f:
|
||||
json.dump({"status": "scanning", "current": item['path'], "processed": processed_files}, f)
|
||||
|
||||
log("Scan completed.")
|
||||
with open(progress_file, 'w') as f:
|
||||
json.dump({"status": "completed", "processed": processed_files}, f)
|
||||
|
||||
dirs_to_scan = subdirs + dirs_to_scan
|
||||
|
||||
if os.path.exists(progress_file):
|
||||
os.remove(progress_file)
|
||||
|
||||
with open(state_file, 'w') as f:
|
||||
for d in dirs_to_scan:
|
||||
f.write(d + '\n')
|
||||
|
||||
if os.path.exists(state_file):
|
||||
os.remove(state_file)
|
||||
|
||||
logger.info("Video scan complete.")
|
||||
return {"status": "Scan complete"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred during video scan: {e}", exc_info=True)
|
||||
return {"status": "Scan failed", "error": str(e)}
|
||||
log(f"Scan failed: {e}")
|
||||
with open(progress_file, 'w') as f:
|
||||
json.dump({"status": "failed", "error": str(e)}, f)
|
||||
|
||||
def _hamming_distance(self, s1, s2):
|
||||
return sum(c1 != c2 for c1, c2 in zip(s1, s2))
|
||||
|
||||
def _calculate_similarity(self, file1: models.VideoFile, file2: models.VideoFile):
|
||||
size_similarity = 1 - (abs(file1.size - file2.size) / max(file1.size, file2.size))
|
||||
duration_similarity = 1 - (abs(file1.duration - file2.duration) / max(file1.duration, file2.duration))
|
||||
hash_similarity = 1 - (self._hamming_distance(file1.phash, file2.phash) / len(file1.phash))
|
||||
return (size_similarity * 0.2) + (duration_similarity * 0.3) + (hash_similarity * 0.5)
|
||||
|
||||
def find_duplicates(self, threshold=0.95):
|
||||
def find_duplicates(self, threshold=0.95, method='fast'):
|
||||
report = models.DuplicateReport(status="running")
|
||||
self.db.add(report)
|
||||
self.db.commit()
|
||||
|
||||
videos = self.db.query(models.VideoFile).all()
|
||||
groups = []
|
||||
processed_videos = set()
|
||||
processed_ids = set()
|
||||
|
||||
for video1, video2 in itertools.combinations(videos, 2):
|
||||
if video1.id in processed_videos or video2.id in processed_videos:
|
||||
continue
|
||||
|
||||
score = self._calculate_similarity(video1, video2)
|
||||
if score >= threshold:
|
||||
existing_group = None
|
||||
for group in groups:
|
||||
if video1.id in group["video_ids"] or video2.id in group["video_ids"]:
|
||||
existing_group = group
|
||||
break
|
||||
for i in range(len(videos)):
|
||||
if videos[i].id in processed_ids: continue
|
||||
|
||||
group = [videos[i]]
|
||||
scores = []
|
||||
|
||||
for j in range(i + 1, len(videos)):
|
||||
if videos[j].id in processed_ids: continue
|
||||
|
||||
if existing_group:
|
||||
existing_group["video_ids"].add(video1.id)
|
||||
existing_group["video_ids"].add(video2.id)
|
||||
existing_group["scores"].append(score)
|
||||
v1, v2 = videos[i], videos[j]
|
||||
|
||||
score = 0
|
||||
if method == 'scene' and v1.scene_phash and v2.scene_phash:
|
||||
# Compare Scene Hashes
|
||||
dist = imagehash.hex_to_hash(v1.scene_phash) - imagehash.hex_to_hash(v2.scene_phash)
|
||||
score = max(0, 1.0 - (dist / 64.0)) # 64 is typical max distance for 8x8 hash
|
||||
else:
|
||||
groups.append({"video_ids": {video1.id, video2.id}, "scores": [score]})
|
||||
# Standard Comparison
|
||||
dist = imagehash.hex_to_hash(v1.phash) - imagehash.hex_to_hash(v2.phash) if v1.phash and v2.phash else 64
|
||||
hash_sim = max(0, 1.0 - (dist / 64.0))
|
||||
dur_sim = 1.0 - (abs(v1.duration - v2.duration) / max(v1.duration, v2.duration)) if max(v1.duration, v2.duration) > 0 else 1.0
|
||||
size_sim = 1.0 - (abs(v1.size - v2.size) / max(v1.size, v2.size)) if max(v1.size, v2.size) > 0 else 1.0
|
||||
score = (hash_sim * 0.6) + (dur_sim * 0.3) + (size_sim * 0.1)
|
||||
|
||||
processed_videos.add(video1.id)
|
||||
processed_videos.add(video2.id)
|
||||
|
||||
for group_data in groups:
|
||||
avg_score = sum(group_data["scores"]) / len(group_data["scores"])
|
||||
db_group = models.DuplicateFileGroup(report_id=report.id, score=avg_score)
|
||||
self.db.add(db_group)
|
||||
self.db.commit()
|
||||
|
||||
for video_id in group_data["video_ids"]:
|
||||
db_file = models.DuplicateFile(group_id=db_group.id, video_file_id=video_id)
|
||||
self.db.add(db_file)
|
||||
if score >= threshold:
|
||||
group.append(v2)
|
||||
scores.append(score)
|
||||
processed_ids.add(v2.id)
|
||||
|
||||
if len(group) > 1:
|
||||
processed_ids.add(videos[i].id)
|
||||
avg_score = sum(scores) / len(scores)
|
||||
|
||||
db_group = models.DuplicateFileGroup(report_id=report.id, score=avg_score)
|
||||
self.db.add(db_group)
|
||||
self.db.commit()
|
||||
|
||||
for v in group:
|
||||
db_file = models.DuplicateFile(group_id=db_group.id, video_file_id=v.id)
|
||||
self.db.add(db_file)
|
||||
|
||||
report.status = "completed"
|
||||
self.db.commit()
|
||||
|
||||
return {"report_id": report.id, "status": "completed"}
|
||||
return {"report_id": report.id}
|
||||
|
||||
def get_reports(self):
|
||||
return self.db.query(models.DuplicateReport).all()
|
||||
return self.db.query(models.DuplicateReport).order_by(models.DuplicateReport.created_at.desc()).all()
|
||||
|
||||
def get_duplicate_report(self, report_id: int):
|
||||
report = self.db.query(models.DuplicateReport).filter(models.DuplicateReport.id == report_id).first()
|
||||
if not report:
|
||||
return {"error": "Report not found"}
|
||||
|
||||
groups = []
|
||||
for group in report.groups:
|
||||
files = []
|
||||
for duplicate_file in group.files:
|
||||
files.append(duplicate_file.video_file)
|
||||
groups.append({
|
||||
"group_id": group.id,
|
||||
"score": group.score,
|
||||
"files": files,
|
||||
})
|
||||
def get_report(self, report_id):
|
||||
report = self.db.query(models.DuplicateReport).filter_by(id=report_id).first()
|
||||
if not report: return None
|
||||
|
||||
return {
|
||||
"report_id": report.id,
|
||||
"created_at": report.created_at,
|
||||
res = {
|
||||
"id": report.id,
|
||||
"status": report.status,
|
||||
"groups": groups,
|
||||
"date": report.created_at,
|
||||
"groups": []
|
||||
}
|
||||
|
||||
def delete_files(self, filepaths: list[str]):
|
||||
samba_manager = SambaManager(
|
||||
self.settings.samba_server_ip,
|
||||
"isolation", # Assuming all duplicates are in the isolation share
|
||||
self.settings.samba_username,
|
||||
self.settings.samba_password,
|
||||
)
|
||||
try:
|
||||
for filepath in filepaths:
|
||||
# Delete from Samba
|
||||
samba_manager.delete_file(filepath)
|
||||
|
||||
# Delete from database
|
||||
video_file = self.db.query(models.VideoFile).filter_by(filepath=filepath).first()
|
||||
if video_file:
|
||||
# Delete associations in DuplicateFile
|
||||
self.db.query(models.DuplicateFile).filter_by(video_file_id=video_file.id).delete()
|
||||
self.db.delete(video_file)
|
||||
|
||||
self.db.commit()
|
||||
return {"status": "success"}
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error deleting files: {e}", exc_info=True)
|
||||
return {"status": "error", "message": str(e)}
|
||||
finally:
|
||||
samba_manager.close()
|
||||
|
||||
|
||||
for g in report.groups:
|
||||
files = [{
|
||||
"id": f.video_file.id,
|
||||
"path": f.video_file.filepath,
|
||||
"size": f.video_file.size,
|
||||
"duration": f.video_file.duration,
|
||||
"contact_sheet": f.video_file.contact_sheet_path
|
||||
} for f in g.files]
|
||||
res["groups"].append({"id": g.id, "score": g.score, "files": files})
|
||||
return res
|
||||
Reference in New Issue
Block a user