Add cleanup unused videos feature to remove unreferenced video folders

Adds right-click context menu on empty source list space with "Cleanup Unused Videos..."
option that scans type_of_video/ directories for video folders not in the current session,
showing a dialog with checkboxes to delete or move them to .trash/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 12:36:10 +01:00
parent 15c00e5fd2
commit 793ba4243d

View File

@@ -3,6 +3,7 @@
import json
import os
import re
import shutil
from pathlib import Path
from typing import Optional
@@ -3579,6 +3580,11 @@ class SequenceLinkerUI(QWidget):
"""Show context menu for folder type and overlap override."""
item = self.source_list.itemAt(pos)
if item is None:
if self.source_folders:
menu = QMenu(self)
cleanup_action = menu.addAction("Cleanup Unused Videos...")
cleanup_action.triggered.connect(self._show_cleanup_unused_dialog)
menu.exec(self.source_list.mapToGlobal(pos))
return
# Placeholder item → offer "Add Transition Folder..."
@@ -3676,6 +3682,324 @@ class SequenceLinkerUI(QWidget):
menu.exec(self.source_list.mapToGlobal(pos))
@staticmethod
def _dir_size_bytes(path: Path) -> int:
"""Recursively compute directory size in bytes."""
total = 0
try:
with os.scandir(path) as it:
for entry in it:
if entry.is_file(follow_symlinks=False):
total += entry.stat(follow_symlinks=False).st_size
elif entry.is_dir(follow_symlinks=False):
total += SequenceLinkerUI._dir_size_bytes(Path(entry.path))
except PermissionError:
pass
return total
@staticmethod
def _format_size(size_bytes: int) -> str:
"""Format bytes as human-readable string."""
for unit in ('B', 'KB', 'MB', 'GB'):
if size_bytes < 1024 or unit == 'GB':
return f"{size_bytes:.1f} {unit}" if unit != 'B' else f"{size_bytes} B"
size_bytes /= 1024
return f"{size_bytes:.1f} GB"
def _find_unused_video_dirs(self):
"""Find video directories not referenced by current source folders.
Returns (unused_entries, scan_roots) where each entry is a dict with
video_dir, type_dir, name, type_name, size_bytes, has_latent,
latent_path, latent_size.
"""
from config import SUPPORTED_EXTENSIONS
# Build scan roots: grandparent (type_of_video/ level) of each source folder
scan_roots: set[Path] = set()
used_video_dirs: set[Path] = set()
for folder in self.source_folders:
resolved = folder.resolve()
# parent = video_name/, grandparent = type_of_video/
video_dir = resolved.parent
type_dir = video_dir.parent
used_video_dirs.add(video_dir)
# Safety: skip if path is too short or type_dir doesn't exist
if len(type_dir.parts) < 2 or not type_dir.is_dir():
continue
scan_roots.add(type_dir)
unused_entries = []
for scan_root in sorted(scan_roots):
type_name = scan_root.name
try:
children = sorted(scan_root.iterdir())
except PermissionError:
continue
for child in children:
if not child.is_dir():
continue
# Skip hidden dirs (.trash/, .git, etc.)
if child.name.startswith('.'):
continue
# Skip if this video dir is used
if child in used_video_dirs:
continue
# Verify it looks like a video dir: has vid/ subdir or
# any subdir containing images
has_vid_subdir = (child / 'vid').is_dir()
has_image_subdir = False
if not has_vid_subdir:
try:
for sub in child.iterdir():
if sub.is_dir():
# Check if subdir has image files
try:
for f in sub.iterdir():
if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS:
has_image_subdir = True
break
except PermissionError:
pass
if has_image_subdir:
break
except PermissionError:
pass
if not has_vid_subdir and not has_image_subdir:
continue
# Compute sizes
size_bytes = self._dir_size_bytes(child)
# Check for .latent sibling file
latent_path = scan_root / f"{child.name}.latent"
has_latent = latent_path.is_file()
latent_size = latent_path.stat().st_size if has_latent else 0
unused_entries.append({
'video_dir': child,
'type_dir': scan_root,
'name': child.name,
'type_name': type_name,
'size_bytes': size_bytes,
'has_latent': has_latent,
'latent_path': latent_path,
'latent_size': latent_size,
})
return unused_entries, scan_roots
def _show_cleanup_unused_dialog(self) -> None:
"""Show dialog to find and cleanup unused video directories."""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QTreeWidget, QTreeWidgetItem, QHeaderView, QPushButton,
QProgressDialog,
)
# --- Scan with progress dialog ---
progress = QProgressDialog("Scanning for unused video folders...", None, 0, 0, self)
progress.setWindowTitle("Scanning")
progress.setMinimumDuration(0)
progress.setModal(True)
progress.show()
QApplication.processEvents()
unused_entries, scan_roots = self._find_unused_video_dirs()
progress.close()
if not unused_entries:
QMessageBox.information(
self, "Cleanup Unused Videos",
"No unused video folders found."
)
return
# --- Build dialog ---
dlg = QDialog(self)
dlg.setWindowTitle("Cleanup Unused Videos")
dlg.resize(750, 500)
layout = QVBoxLayout(dlg)
total_size = sum(e['size_bytes'] + e['latent_size'] for e in unused_entries)
summary_label = QLabel(
f"Found {len(unused_entries)} unused video folder(s) "
f"({self._format_size(total_size)} total) "
f"across {len(scan_roots)} type folder(s)."
)
layout.addWidget(summary_label)
tree = QTreeWidget()
tree.setHeaderLabels(["Video Name", "Type", "Size", "Latent"])
tree.setRootIsDecorated(False)
tree.setAlternatingRowColors(True)
tree.header().setStretchLastSection(False)
tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
tree.header().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
tree.header().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
layout.addWidget(tree)
for entry in unused_entries:
combined = entry['size_bytes'] + entry['latent_size']
latent_text = self._format_size(entry['latent_size']) if entry['has_latent'] else ""
item = QTreeWidgetItem([
entry['name'],
entry['type_name'],
self._format_size(combined),
latent_text,
])
item.setCheckState(0, Qt.CheckState.Checked)
item.setData(0, Qt.ItemDataRole.UserRole, entry)
item.setToolTip(0, str(entry['video_dir']))
tree.addTopLevelItem(item)
# --- Check All / Uncheck All ---
check_layout = QHBoxLayout()
check_all_btn = QPushButton("Check All")
uncheck_all_btn = QPushButton("Uncheck All")
def set_all_checked(state):
for i in range(tree.topLevelItemCount()):
tree.topLevelItem(i).setCheckState(0, state)
check_all_btn.clicked.connect(lambda: set_all_checked(Qt.CheckState.Checked))
uncheck_all_btn.clicked.connect(lambda: set_all_checked(Qt.CheckState.Unchecked))
check_layout.addWidget(check_all_btn)
check_layout.addWidget(uncheck_all_btn)
check_layout.addStretch()
layout.addLayout(check_layout)
# --- Action buttons ---
btn_layout = QHBoxLayout()
delete_btn = QPushButton("Delete Selected")
delete_btn.setStyleSheet("color: red;")
trash_btn = QPushButton("Move to .trash/")
close_btn = QPushButton("Close")
def get_checked_entries():
entries = []
for i in range(tree.topLevelItemCount()):
ti = tree.topLevelItem(i)
if ti.checkState(0) == Qt.CheckState.Checked:
entries.append((i, ti.data(0, Qt.ItemDataRole.UserRole)))
return entries
def remove_processed_items(indices):
for i in sorted(indices, reverse=True):
tree.takeTopLevelItem(i)
# Update summary
remaining = tree.topLevelItemCount()
if remaining == 0:
summary_label.setText("All selected folders processed.")
else:
rem_size = 0
for i in range(remaining):
e = tree.topLevelItem(i).data(0, Qt.ItemDataRole.UserRole)
rem_size += e['size_bytes'] + e['latent_size']
summary_label.setText(
f"{remaining} unused video folder(s) remaining "
f"({self._format_size(rem_size)} total)."
)
def do_delete():
checked = get_checked_entries()
if not checked:
return
reply = QMessageBox.warning(
dlg, "Delete Permanently",
f"Permanently delete {len(checked)} video folder(s) "
f"and their .latent files?\n\nThis cannot be undone.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
success = 0
errors = []
processed_indices = []
for idx, entry in checked:
try:
shutil.rmtree(entry['video_dir'])
if entry['has_latent'] and entry['latent_path'].exists():
entry['latent_path'].unlink()
success += 1
processed_indices.append(idx)
except Exception as e:
errors.append(f"{entry['name']}: {e}")
remove_processed_items(processed_indices)
msg = f"Deleted {success} folder(s)."
if errors:
msg += f"\n\n{len(errors)} error(s):\n" + "\n".join(errors)
QMessageBox.information(dlg, "Delete Complete", msg)
def do_trash():
checked = get_checked_entries()
if not checked:
return
reply = QMessageBox.question(
dlg, "Move to .trash/",
f"Move {len(checked)} video folder(s) to .trash/?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
success = 0
errors = []
processed_indices = []
for idx, entry in checked:
try:
trash_dir = entry['type_dir'] / '.trash'
trash_dir.mkdir(exist_ok=True)
dest = trash_dir / entry['name']
if dest.exists():
shutil.rmtree(dest)
shutil.move(str(entry['video_dir']), str(dest))
if entry['has_latent'] and entry['latent_path'].exists():
latent_dest = trash_dir / entry['latent_path'].name
if latent_dest.exists():
latent_dest.unlink()
shutil.move(str(entry['latent_path']), str(latent_dest))
success += 1
processed_indices.append(idx)
except Exception as e:
errors.append(f"{entry['name']}: {e}")
remove_processed_items(processed_indices)
msg = f"Moved {success} folder(s) to .trash/."
if errors:
msg += f"\n\n{len(errors)} error(s):\n" + "\n".join(errors)
QMessageBox.information(dlg, "Trash Complete", msg)
delete_btn.clicked.connect(do_delete)
trash_btn.clicked.connect(do_trash)
close_btn.clicked.connect(dlg.close)
btn_layout.addWidget(delete_btn)
btn_layout.addWidget(trash_btn)
btn_layout.addStretch()
btn_layout.addWidget(close_btn)
layout.addLayout(btn_layout)
dlg.exec()
def _show_file_list_context_menu(self, pos: QPoint) -> None:
"""Show context menu for file list items (split, remove)."""
item = self.file_list.itemAt(pos)