823 lines
No EOL
28 KiB
Python
823 lines
No EOL
28 KiB
Python
import csv
|
||
import glob
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
from PyQt6.QtCore import QThread, Qt, pyqtSignal
|
||
from PyQt6.QtGui import QColor, QFont
|
||
from PyQt6.QtWidgets import (
|
||
QApplication,
|
||
QComboBox,
|
||
QFileDialog,
|
||
QGridLayout,
|
||
QGroupBox,
|
||
QHBoxLayout,
|
||
QHeaderView,
|
||
QLabel,
|
||
QLineEdit,
|
||
QMainWindow,
|
||
QMenu,
|
||
QMessageBox,
|
||
QProgressBar,
|
||
QPushButton,
|
||
QRadioButton,
|
||
QTableWidget,
|
||
QTableWidgetItem,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
# ================= STANDARD-WERTE FÜR DIE GUI =================
|
||
DEFAULT_VIDEO_DIR = r"\\192.168.180.78\fotos\2026"
|
||
DEFAULT_EXCLUDES = "#recycle, @eaDir, Backup_Alt, 00immich_upload, Jahre, komp"
|
||
|
||
# Unterstützte Video- und Foto-Formate
|
||
SUPPORTED_EXTENSIONS = [
|
||
# Videos
|
||
"mp4",
|
||
"mov",
|
||
"mkv",
|
||
# Fotos & RAWs
|
||
"jpg",
|
||
"jpeg",
|
||
"png",
|
||
"dng",
|
||
"arw",
|
||
"heic",
|
||
"tif",
|
||
"tiff",
|
||
"webp",
|
||
]
|
||
# =============================================================
|
||
|
||
# ================= EXIFTOOL-SUCHE =================
|
||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
found_exes = [
|
||
f
|
||
for f in glob.glob(
|
||
os.path.join(SCRIPT_DIR, "**", "exiftool.exe"), recursive=True
|
||
)
|
||
if "(-k)" not in f
|
||
]
|
||
EXIFTOOL_PATH = found_exes[0] if found_exes else None
|
||
# =================================================
|
||
|
||
|
||
def scan_metadata(folder, exclude_dirs):
|
||
cmd = [
|
||
EXIFTOOL_PATH,
|
||
"-r",
|
||
"-json",
|
||
"-q",
|
||
"-m",
|
||
"-charset",
|
||
"utf8",
|
||
"-charset",
|
||
"filename=utf8",
|
||
]
|
||
|
||
for exc in exclude_dirs:
|
||
if "\\" not in exc and "/" not in exc:
|
||
cmd.extend(["-i", exc])
|
||
|
||
for ext in SUPPORTED_EXTENSIONS:
|
||
cmd.extend(["-ext", ext])
|
||
|
||
cmd.extend([
|
||
"-FileName",
|
||
"-Directory",
|
||
"-Software",
|
||
"-Encoder",
|
||
"-CompressorName",
|
||
"-HandlerDescription",
|
||
"-Make",
|
||
"-Model",
|
||
folder,
|
||
])
|
||
|
||
try:
|
||
res = subprocess.run(
|
||
cmd,
|
||
capture_output=True,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
)
|
||
except Exception as e:
|
||
return None, f"Subprocess-Fehler: {e}"
|
||
|
||
stdout_clean = res.stdout.strip() if res.stdout else ""
|
||
|
||
if stdout_clean.startswith("[") and stdout_clean.endswith("]"):
|
||
try:
|
||
return json.loads(stdout_clean), None
|
||
except json.JSONDecodeError as e:
|
||
return None, f"Fehler beim Decodieren des JSON: {e}"
|
||
|
||
return None, res.stderr or "Keine Daten von ExifTool empfangen."
|
||
|
||
|
||
def classify_media(item):
|
||
filename = item.get("FileName", "").lower()
|
||
software = str(item.get("Software", "")).lower()
|
||
encoder = str(item.get("Encoder", "")).lower()
|
||
compressor = str(item.get("CompressorName", "")).lower()
|
||
handler = str(item.get("HandlerDescription", "")).lower()
|
||
make = str(item.get("Make", "")).lower()
|
||
model = str(item.get("Model", "")).lower()
|
||
|
||
combined_info = (
|
||
f"{software} {encoder} {compressor} {handler} {filename}".lower()
|
||
)
|
||
|
||
# 1. Fotos (Lightroom, Photoshop, etc.)
|
||
if "lightroom" in combined_info or "lrm_export" in filename:
|
||
return "Lightroom", "Adobe Lightroom"
|
||
if any(ps in combined_info for ps in ["photoshop", "imageready"]):
|
||
return "Edit", "Adobe Photoshop"
|
||
if "helicon" in combined_info:
|
||
return "Edit", "Helicon Focus"
|
||
if any(
|
||
pe in combined_info
|
||
for pe in ["snapseed", "gimp", "affinity", "capture one"]
|
||
):
|
||
return "Edit", "Foto-Editor"
|
||
|
||
# 2. Videoschnitt-Programme
|
||
if "quik" in combined_info:
|
||
return "Edit", "GoPro Quik"
|
||
if "gopro" in software and not ("hero" in model):
|
||
return "Edit", "GoPro App/Quik"
|
||
if any(m in combined_info for m in ["magix", "movie edit pro", "deluxe"]):
|
||
return "Edit", "Magix"
|
||
if any(
|
||
w in combined_info
|
||
for w in ["movie maker", "clipchamp", "microsoft corporation"]
|
||
):
|
||
return "Edit", "Windows Video Tool"
|
||
if any(c in combined_info for c in ["capcut", "bytedance", "lemon"]):
|
||
return "Trim", "CapCut"
|
||
if "inshot" in combined_info:
|
||
return "Trim", "InShot"
|
||
|
||
# 3. Video-Exporte ohne Make/Model
|
||
ext = os.path.splitext(filename)[1].lower()
|
||
if not make and not model:
|
||
if ext in [".mp4", ".mov", ".mkv"] and any(
|
||
g in combined_info for g in ["lavf", "handbrake", "x264", "x265"]
|
||
):
|
||
return "Edit", "Unbekannter Export (Lavf/x264)"
|
||
|
||
return None, "Original"
|
||
|
||
|
||
def write_sidecar(file_path, tag):
|
||
xmp_path = f"{file_path}.xmp"
|
||
cmd = [
|
||
EXIFTOOL_PATH,
|
||
"-tagsfromfile",
|
||
file_path,
|
||
f"-XMP-digiKam:TagsList={tag}",
|
||
f"-XMP-lr:HierarchicalSubject={tag}",
|
||
f"-XMP-dc:Subject={tag}",
|
||
f"-IPTC:Keywords={tag}",
|
||
"-o",
|
||
xmp_path,
|
||
file_path,
|
||
]
|
||
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
|
||
|
||
def write_embedded(file_path, tag):
|
||
cmd = [
|
||
EXIFTOOL_PATH,
|
||
"-P",
|
||
"-overwrite_original",
|
||
# Für Fotos im Windows Explorer ("Markierungen" in JPG/TIF):
|
||
f"-XPKeywords={tag}",
|
||
f"-IPTC:Keywords={tag}",
|
||
# Für Videos im Windows Explorer ("Markierungen" in MP4):
|
||
f"-Microsoft:Category={tag}",
|
||
# Für Immich & Adobe Standards:
|
||
f"-XMP-digiKam:TagsList={tag}",
|
||
f"-XMP-lr:HierarchicalSubject={tag}",
|
||
f"-XMP-dc:Subject={tag}",
|
||
# Apple/QuickTime:
|
||
f"-QuickTime:Keywords={tag}",
|
||
f"-ItemList:Keyword={tag}",
|
||
file_path,
|
||
]
|
||
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
|
||
|
||
# ================= THREADS =================
|
||
class ScanThread(QThread):
|
||
status_signal = pyqtSignal(str)
|
||
error_signal = pyqtSignal(str)
|
||
finished_signal = pyqtSignal(list)
|
||
|
||
def __init__(self, video_dir, exclude_dirs):
|
||
super().__init__()
|
||
self.video_dir = video_dir
|
||
self.exclude_dirs = exclude_dirs
|
||
|
||
def run(self):
|
||
self.status_signal.emit(
|
||
f"Scanne Metadaten in '{self.video_dir}' (Netzwerk-Scan läuft)..."
|
||
)
|
||
items, error = scan_metadata(self.video_dir, self.exclude_dirs)
|
||
|
||
if error or items is None:
|
||
self.error_signal.emit(error or "Keine Daten empfangen.")
|
||
return
|
||
|
||
detected_files = []
|
||
for item in items:
|
||
directory = item.get("Directory", "")
|
||
|
||
skip_file = False
|
||
for exclude_path in self.exclude_dirs:
|
||
if (
|
||
os.path.normpath(exclude_path).lower()
|
||
in os.path.normpath(directory).lower()
|
||
):
|
||
skip_file = True
|
||
break
|
||
|
||
if skip_file:
|
||
continue
|
||
|
||
tag, detected_app = classify_media(item)
|
||
if tag:
|
||
detected_files.append({
|
||
"File": item.get("SourceFile"),
|
||
"Suggested_Tag": tag,
|
||
"Program": detected_app,
|
||
})
|
||
|
||
self.finished_signal.emit(detected_files)
|
||
|
||
|
||
class ApplyThread(QThread):
|
||
status_signal = pyqtSignal(str)
|
||
progress_signal = pyqtSignal(int, int)
|
||
finished_signal = pyqtSignal(int)
|
||
|
||
def __init__(self, tasks, mode):
|
||
super().__init__()
|
||
self.tasks = tasks
|
||
self.mode = mode
|
||
|
||
def run(self):
|
||
total = len(self.tasks)
|
||
for i, (path, tag) in enumerate(self.tasks, 1):
|
||
self.status_signal.emit(f"Verarbeite Datei {i} von {total}...")
|
||
self.progress_signal.emit(i, total)
|
||
if self.mode == "SIDECAR":
|
||
write_sidecar(path, tag)
|
||
else:
|
||
write_embedded(path, tag)
|
||
self.finished_signal.emit(total)
|
||
|
||
|
||
# ================= GUI KLASSE =================
|
||
class EditFinderApp(QMainWindow):
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle("Immich Media Tagger (Videos & Fotos)")
|
||
self.resize(1250, 780)
|
||
self.setAcceptDrops(True)
|
||
|
||
self.is_syncing_combos = False
|
||
|
||
self.setup_ui()
|
||
self.statusBar().showMessage("Bereit.")
|
||
|
||
def setup_ui(self):
|
||
central_widget = QWidget()
|
||
self.setCentralWidget(central_widget)
|
||
main_layout = QVBoxLayout(central_widget)
|
||
|
||
# --- Konfigurationsbereich ---
|
||
config_group = QGroupBox("Konfiguration")
|
||
config_layout = QGridLayout(config_group)
|
||
|
||
lbl_dir = QLabel("Zielordner:")
|
||
self.input_dir = QLineEdit(DEFAULT_VIDEO_DIR)
|
||
self.btn_browse = QPushButton("📁 Durchsuchen...")
|
||
self.btn_browse.clicked.connect(self.browse_folder)
|
||
|
||
config_layout.addWidget(lbl_dir, 0, 0)
|
||
config_layout.addWidget(self.input_dir, 0, 1)
|
||
config_layout.addWidget(self.btn_browse, 0, 2)
|
||
|
||
lbl_exclude = QLabel("Ausnahmen (kommagetrennt):")
|
||
self.input_exclude = QLineEdit(DEFAULT_EXCLUDES)
|
||
|
||
config_layout.addWidget(lbl_exclude, 1, 0)
|
||
config_layout.addWidget(self.input_exclude, 1, 1, 1, 2)
|
||
|
||
main_layout.addWidget(config_group)
|
||
|
||
# --- Scan & Import Buttons ---
|
||
action_bar_layout = QHBoxLayout()
|
||
|
||
self.btn_scan = QPushButton("1. Ordner scannen")
|
||
self.btn_scan.setMinimumHeight(35)
|
||
self.btn_scan.clicked.connect(self.start_scan)
|
||
|
||
self.btn_add_manual = QPushButton("➕ Dateien hinzufügen...")
|
||
self.btn_add_manual.setMinimumHeight(35)
|
||
self.btn_add_manual.clicked.connect(self.browse_manual_files)
|
||
|
||
# NEUER BUTTON: Ganzen Ordner inkl. Unterordner hinzufügen
|
||
self.btn_add_folder = QPushButton("📁 Ordner hinzufügen (rekursiv)...")
|
||
self.btn_add_folder.setMinimumHeight(35)
|
||
self.btn_add_folder.clicked.connect(self.browse_manual_folder)
|
||
|
||
self.btn_clear = QPushButton("🗑️ Liste leeren")
|
||
self.btn_clear.setMinimumHeight(35)
|
||
self.btn_clear.clicked.connect(self.clear_table)
|
||
|
||
self.progress_bar = QProgressBar()
|
||
self.progress_bar.setMinimumHeight(25)
|
||
self.progress_bar.setTextVisible(True)
|
||
self.progress_bar.setValue(0)
|
||
|
||
action_bar_layout.addWidget(self.btn_scan, 1)
|
||
action_bar_layout.addWidget(self.btn_add_manual, 1)
|
||
action_bar_layout.addWidget(self.btn_add_folder, 1)
|
||
action_bar_layout.addWidget(self.btn_clear, 1)
|
||
action_bar_layout.addWidget(self.progress_bar, 2)
|
||
main_layout.addLayout(action_bar_layout)
|
||
|
||
# --- Massenauswahl-Leiste ---
|
||
mass_group = QGroupBox("Massenbearbeitung")
|
||
mass_layout = QHBoxLayout(mass_group)
|
||
|
||
self.btn_select_all = QPushButton("☑ Alle auswählen")
|
||
self.btn_select_all.clicked.connect(lambda: self.set_all_checks(True))
|
||
|
||
self.btn_deselect_all = QPushButton("☐ Keine auswählen")
|
||
self.btn_deselect_all.clicked.connect(lambda: self.set_all_checks(False))
|
||
|
||
lbl_mass = QLabel("Für markierte Zeilen Tag setzen:")
|
||
self.combo_mass = QComboBox()
|
||
self.combo_mass.addItems(["Ignorieren", "Edit", "Trim", "Lightroom"])
|
||
self.combo_mass.setCurrentText("Edit")
|
||
|
||
self.btn_apply_mass = QPushButton("Auf Markierte anwenden")
|
||
self.btn_apply_mass.clicked.connect(self.apply_mass_tag)
|
||
|
||
mass_layout.addWidget(self.btn_select_all)
|
||
mass_layout.addWidget(self.btn_deselect_all)
|
||
mass_layout.addSpacing(25)
|
||
mass_layout.addWidget(lbl_mass)
|
||
mass_layout.addWidget(self.combo_mass)
|
||
mass_layout.addWidget(self.btn_apply_mass)
|
||
mass_layout.addStretch()
|
||
|
||
main_layout.addWidget(mass_group)
|
||
|
||
# --- Tabelle ---
|
||
self.table = QTableWidget(0, 5)
|
||
self.table.setHorizontalHeaderLabels([
|
||
"✓",
|
||
"Dateiname (Klick zum Öffnen)",
|
||
"Erkanntes Programm",
|
||
"Vorgeschlagen",
|
||
"Aktion",
|
||
])
|
||
|
||
header = self.table.horizontalHeader()
|
||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive)
|
||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive)
|
||
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Interactive)
|
||
|
||
self.table.setColumnWidth(0, 35)
|
||
self.table.setColumnWidth(2, 170)
|
||
self.table.setColumnWidth(3, 110)
|
||
self.table.setColumnWidth(4, 140)
|
||
|
||
self.table.cellClicked.connect(self.on_cell_clicked)
|
||
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||
self.table.customContextMenuRequested.connect(self.open_context_menu)
|
||
|
||
main_layout.addWidget(self.table)
|
||
|
||
# --- Footer ---
|
||
bottom_layout = QHBoxLayout()
|
||
|
||
self.radio_sidecar = QRadioButton("XMP Sidecar erzeugen (Empfohlen)")
|
||
self.radio_sidecar.setChecked(True)
|
||
self.radio_embedded = QRadioButton(
|
||
"Direkt in Datei schreiben (Embedded)"
|
||
)
|
||
|
||
self.btn_apply = QPushButton("2. Tags anwenden")
|
||
self.btn_apply.setMinimumHeight(35)
|
||
self.btn_apply.setEnabled(False)
|
||
self.btn_apply.clicked.connect(self.start_apply)
|
||
|
||
bottom_layout.addWidget(self.radio_sidecar)
|
||
bottom_layout.addWidget(self.radio_embedded)
|
||
bottom_layout.addStretch()
|
||
bottom_layout.addWidget(self.btn_apply)
|
||
|
||
main_layout.addLayout(bottom_layout)
|
||
|
||
# --- Tabelle komplett leeren ---
|
||
def clear_table(self):
|
||
if self.table.rowCount() == 0:
|
||
self.statusBar().showMessage("Die Liste ist bereits leer.")
|
||
return
|
||
|
||
reply = QMessageBox.question(
|
||
self,
|
||
"Liste leeren",
|
||
f"Möchtest du wirklich alle {self.table.rowCount()} Einträge aus der Tabelle entfernen?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
|
||
if reply == QMessageBox.StandardButton.Yes:
|
||
self.table.setRowCount(0)
|
||
self.btn_apply.setEnabled(False)
|
||
self.progress_bar.setRange(0, 100)
|
||
self.progress_bar.setValue(0)
|
||
self.progress_bar.setFormat("")
|
||
self.statusBar().showMessage("Tabelle wurde geleert.")
|
||
|
||
# --- Zeile in Tabelle einfügen ---
|
||
def insert_table_row(
|
||
self, file_path, program, suggested, default_action="Ignorieren"
|
||
):
|
||
for r in range(self.table.rowCount()):
|
||
existing_item = self.table.item(r, 1)
|
||
if (
|
||
existing_item
|
||
and existing_item.data(Qt.ItemDataRole.UserRole) == file_path
|
||
):
|
||
return False
|
||
|
||
row = self.table.rowCount()
|
||
self.table.insertRow(row)
|
||
|
||
# 0. Checkbox-Zelle
|
||
check_item = QTableWidgetItem()
|
||
check_item.setFlags(
|
||
Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled
|
||
)
|
||
check_item.setCheckState(Qt.CheckState.Unchecked)
|
||
self.table.setItem(row, 0, check_item)
|
||
|
||
# 1. Dateiname
|
||
short_name = os.path.basename(file_path)
|
||
parent_dir = os.path.basename(os.path.dirname(file_path))
|
||
display_name = f"{parent_dir}/{short_name}"
|
||
|
||
link_font = QFont()
|
||
link_font.setUnderline(True)
|
||
link_color = QColor("#0066cc")
|
||
|
||
item_name = QTableWidgetItem(f"▶ {display_name}")
|
||
item_name.setFont(link_font)
|
||
item_name.setForeground(link_color)
|
||
item_name.setToolTip(
|
||
f"Klicken zum Öffnen:\n{file_path}\n(Rechtsklick für Ordner)"
|
||
)
|
||
item_name.setData(Qt.ItemDataRole.UserRole, file_path)
|
||
item_name.setFlags(item_name.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
self.table.setItem(row, 1, item_name)
|
||
|
||
# 2. Programm
|
||
item_prog = QTableWidgetItem(program)
|
||
item_prog.setFlags(item_prog.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
self.table.setItem(row, 2, item_prog)
|
||
|
||
# 3. Vorgeschlagen
|
||
item_sugg = QTableWidgetItem(suggested)
|
||
item_sugg.setFlags(item_sugg.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
self.table.setItem(row, 3, item_sugg)
|
||
|
||
# 4. Aktion Dropdown
|
||
combo = QComboBox()
|
||
combo.addItems(["Ignorieren", "Edit", "Trim", "Lightroom"])
|
||
combo.setCurrentText(default_action)
|
||
|
||
combo.currentTextChanged.connect(
|
||
lambda text, c=combo: self.on_row_combo_changed(c, text)
|
||
)
|
||
self.table.setCellWidget(row, 4, combo)
|
||
|
||
return True
|
||
|
||
def on_row_combo_changed(self, trigger_combo, new_text):
|
||
if self.is_syncing_combos:
|
||
return
|
||
|
||
trigger_row = -1
|
||
for r in range(self.table.rowCount()):
|
||
if self.table.cellWidget(r, 4) == trigger_combo:
|
||
trigger_row = r
|
||
break
|
||
|
||
if trigger_row == -1:
|
||
return
|
||
|
||
check_item = self.table.item(trigger_row, 0)
|
||
if check_item and check_item.checkState() == Qt.CheckState.Checked:
|
||
self.is_syncing_combos = True
|
||
for r in range(self.table.rowCount()):
|
||
c_item = self.table.item(r, 0)
|
||
if (
|
||
c_item
|
||
and c_item.checkState() == Qt.CheckState.Checked
|
||
and r != trigger_row
|
||
):
|
||
other_combo = self.table.cellWidget(r, 4)
|
||
if other_combo:
|
||
other_combo.setCurrentText(new_text)
|
||
self.is_syncing_combos = False
|
||
|
||
def set_all_checks(self, state: bool):
|
||
target_state = Qt.CheckState.Checked if state else Qt.CheckState.Unchecked
|
||
for r in range(self.table.rowCount()):
|
||
item = self.table.item(r, 0)
|
||
if item:
|
||
item.setCheckState(target_state)
|
||
|
||
def apply_mass_tag(self):
|
||
new_tag = self.combo_mass.currentText()
|
||
count = 0
|
||
self.is_syncing_combos = True
|
||
for r in range(self.table.rowCount()):
|
||
check_item = self.table.item(r, 0)
|
||
if check_item and check_item.checkState() == Qt.CheckState.Checked:
|
||
combo = self.table.cellWidget(r, 4)
|
||
if combo:
|
||
combo.setCurrentText(new_tag)
|
||
count += 1
|
||
self.is_syncing_combos = False
|
||
self.statusBar().showMessage(
|
||
f"Tag '{new_tag}' auf {count} markierte Dateien angewendet."
|
||
)
|
||
|
||
# --- Einzelne Dateien manuell auswählen ---
|
||
def browse_manual_files(self):
|
||
ext_filter = " ".join([f"*.{e}" for e in SUPPORTED_EXTENSIONS])
|
||
files, _ = QFileDialog.getOpenFileNames(
|
||
self,
|
||
"Medien manuell auswählen",
|
||
self.input_dir.text(),
|
||
f"Unterstützte Medien ({ext_filter});;Alle Dateien (*.*)",
|
||
)
|
||
if files:
|
||
self.table.setUpdatesEnabled(False)
|
||
added_count = 0
|
||
for f in files:
|
||
norm_p = os.path.normpath(f)
|
||
if self.insert_table_row(
|
||
norm_p, "Manuell hinzugefügt", "Edit", default_action="Edit"
|
||
):
|
||
added_count += 1
|
||
self.table.setUpdatesEnabled(True)
|
||
|
||
self.btn_apply.setEnabled(self.table.rowCount() > 0)
|
||
self.statusBar().showMessage(
|
||
f"{added_count} Datei(en) manuell zur Tabelle hinzugefügt."
|
||
)
|
||
|
||
# --- Ganzen Ordner rekursiv (inkl. aller Unterordner) hinzufügen ---
|
||
def browse_manual_folder(self):
|
||
folder = QFileDialog.getExistingDirectory(
|
||
self, "Ordner auswählen (inkl. Unterordner)", self.input_dir.text()
|
||
)
|
||
if folder:
|
||
self.statusBar().showMessage(f"Durchsuche Ordner '{folder}'...")
|
||
valid_exts = tuple(f".{e}" for e in SUPPORTED_EXTENSIONS)
|
||
matched_files = []
|
||
|
||
# Rekursiver Scan durch alle Unterverzeichnisse
|
||
for root, _, files in os.walk(folder):
|
||
for f in files:
|
||
if f.lower().endswith(valid_exts):
|
||
matched_files.append(os.path.normpath(os.path.join(root, f)))
|
||
|
||
self.table.setUpdatesEnabled(False)
|
||
added_count = 0
|
||
for p in matched_files:
|
||
if self.insert_table_row(
|
||
p, "Ordner-Import", "Edit", default_action="Edit"
|
||
):
|
||
added_count += 1
|
||
self.table.setUpdatesEnabled(True)
|
||
|
||
self.btn_apply.setEnabled(self.table.rowCount() > 0)
|
||
self.statusBar().showMessage(
|
||
f"{added_count} Datei(en) aus Ordnerstruktur hinzugefügt."
|
||
)
|
||
|
||
# --- Drag & Drop Unterstützung (Dateien und ganze Ordner) ---
|
||
def dragEnterEvent(self, event):
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
|
||
def dropEvent(self, event):
|
||
paths = []
|
||
valid_exts = tuple(f".{e}" for e in SUPPORTED_EXTENSIONS)
|
||
|
||
for url in event.mimeData().urls():
|
||
p = url.toLocalFile()
|
||
if os.path.isfile(p) and p.lower().endswith(valid_exts):
|
||
paths.append(os.path.normpath(p))
|
||
elif os.path.isdir(p):
|
||
# Ganze gedroppte Ordner rekursiv durchsuchen
|
||
for root, _, files in os.walk(p):
|
||
for f in files:
|
||
if f.lower().endswith(valid_exts):
|
||
paths.append(os.path.normpath(os.path.join(root, f)))
|
||
|
||
if paths:
|
||
self.table.setUpdatesEnabled(False)
|
||
added_count = 0
|
||
for p in paths:
|
||
if self.insert_table_row(
|
||
p, "Drag & Drop", "Edit", default_action="Edit"
|
||
):
|
||
added_count += 1
|
||
self.table.setUpdatesEnabled(True)
|
||
|
||
self.btn_apply.setEnabled(self.table.rowCount() > 0)
|
||
self.statusBar().showMessage(
|
||
f"{added_count} Datei(en) per Drag & Drop hinzugefügt."
|
||
)
|
||
|
||
def browse_folder(self):
|
||
folder = QFileDialog.getExistingDirectory(
|
||
self, "Zielordner auswählen", self.input_dir.text()
|
||
)
|
||
if folder:
|
||
self.input_dir.setText(os.path.normpath(folder))
|
||
|
||
def start_scan(self):
|
||
video_dir = self.input_dir.text().strip()
|
||
if not os.path.exists(video_dir):
|
||
QMessageBox.warning(
|
||
self,
|
||
"Fehler",
|
||
f"Der Ordner '{video_dir}' existiert nicht oder ist nicht"
|
||
" erreichbar.",
|
||
)
|
||
return
|
||
|
||
raw_excludes = self.input_exclude.text().split(",")
|
||
exclude_dirs = [x.strip() for x in raw_excludes if x.strip()]
|
||
|
||
self.btn_scan.setEnabled(False)
|
||
self.btn_apply.setEnabled(False)
|
||
self.table.setRowCount(0)
|
||
|
||
self.progress_bar.setRange(0, 0)
|
||
self.progress_bar.setFormat("Scanne Medien...")
|
||
|
||
self.scan_thread = ScanThread(video_dir, exclude_dirs)
|
||
self.scan_thread.status_signal.connect(self.statusBar().showMessage)
|
||
self.scan_thread.error_signal.connect(self.show_error)
|
||
self.scan_thread.finished_signal.connect(self.populate_table)
|
||
self.scan_thread.start()
|
||
|
||
def show_error(self, error_msg):
|
||
self.progress_bar.setRange(0, 100)
|
||
self.progress_bar.setValue(0)
|
||
self.progress_bar.setFormat("Fehler aufgetreten")
|
||
self.btn_scan.setEnabled(True)
|
||
self.statusBar().showMessage("Fehler beim Scannen!")
|
||
QMessageBox.critical(self, "Scan Fehler", error_msg)
|
||
|
||
def populate_table(self, files):
|
||
self.statusBar().showMessage(
|
||
f"Scan abgeschlossen: {len(files)} Treffer geladen."
|
||
)
|
||
|
||
self.table.setUpdatesEnabled(False)
|
||
for f in files:
|
||
self.insert_table_row(
|
||
f["File"],
|
||
f["Program"],
|
||
f["Suggested_Tag"],
|
||
default_action="Ignorieren",
|
||
)
|
||
self.table.setUpdatesEnabled(True)
|
||
|
||
self.progress_bar.setRange(0, 100)
|
||
self.progress_bar.setValue(100)
|
||
self.progress_bar.setFormat(f"Scan fertig ({len(files)} Treffer)")
|
||
|
||
self.btn_scan.setEnabled(True)
|
||
if self.table.rowCount() > 0:
|
||
self.btn_apply.setEnabled(True)
|
||
|
||
def on_cell_clicked(self, row, column):
|
||
if column == 1:
|
||
item = self.table.item(row, 1)
|
||
if item:
|
||
file_path = item.data(Qt.ItemDataRole.UserRole)
|
||
if file_path and os.path.exists(file_path):
|
||
self.statusBar().showMessage(
|
||
f"Öffne Datei: {os.path.basename(file_path)}..."
|
||
)
|
||
try:
|
||
os.startfile(file_path)
|
||
except Exception as e:
|
||
QMessageBox.warning(
|
||
self, "Fehler", f"Konnte Datei nicht öffnen:\n{e}"
|
||
)
|
||
else:
|
||
QMessageBox.warning(
|
||
self, "Nicht gefunden", "Datei ist nicht mehr erreichbar."
|
||
)
|
||
|
||
def open_context_menu(self, position):
|
||
row = self.table.rowAt(position.y())
|
||
if row >= 0:
|
||
item = self.table.item(row, 1)
|
||
if item:
|
||
file_path = item.data(Qt.ItemDataRole.UserRole)
|
||
menu = QMenu()
|
||
action_play = menu.addAction("▶ Datei öffnen")
|
||
action_explorer = menu.addAction("📁 Im Explorer anzeigen")
|
||
|
||
action = menu.exec(self.table.viewport().mapToGlobal(position))
|
||
if action == action_play:
|
||
os.startfile(file_path)
|
||
elif action == action_explorer:
|
||
subprocess.Popen(f'explorer /select,"{file_path}"')
|
||
|
||
def start_apply(self):
|
||
tasks = []
|
||
for row in range(self.table.rowCount()):
|
||
file_path = self.table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
combo = self.table.cellWidget(row, 4)
|
||
tag = combo.currentText()
|
||
|
||
if tag != "Ignorieren":
|
||
tasks.append((file_path, tag))
|
||
|
||
if not tasks:
|
||
QMessageBox.information(
|
||
self,
|
||
"Hinweis",
|
||
"Alle Dateien stehen auf 'Ignorieren'. Nichts zu tun.",
|
||
)
|
||
return
|
||
|
||
self.btn_scan.setEnabled(False)
|
||
self.btn_apply.setEnabled(False)
|
||
|
||
self.progress_bar.setRange(0, len(tasks))
|
||
self.progress_bar.setValue(0)
|
||
self.progress_bar.setFormat("Schreibe Tags: %v / %m (%p%)")
|
||
|
||
mode = "SIDECAR" if self.radio_sidecar.isChecked() else "EMBEDDED"
|
||
|
||
self.apply_thread = ApplyThread(tasks, mode)
|
||
self.apply_thread.status_signal.connect(self.statusBar().showMessage)
|
||
self.apply_thread.progress_signal.connect(self.update_apply_progress)
|
||
self.apply_thread.finished_signal.connect(self.finish_apply)
|
||
self.apply_thread.start()
|
||
|
||
def update_apply_progress(self, current, total):
|
||
self.progress_bar.setValue(current)
|
||
|
||
def finish_apply(self, total):
|
||
self.statusBar().showMessage(
|
||
f"Fertig! {total} Dateien erfolgreich getaggt."
|
||
)
|
||
self.progress_bar.setValue(total)
|
||
self.progress_bar.setFormat("Abgeschlossen")
|
||
self.btn_scan.setEnabled(True)
|
||
self.btn_apply.setEnabled(True)
|
||
QMessageBox.information(
|
||
self, "Abgeschlossen", f"Es wurden {total} Dateien verarbeitet."
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app = QApplication(sys.argv)
|
||
|
||
if not EXIFTOOL_PATH:
|
||
QMessageBox.critical(
|
||
None,
|
||
"Fehler",
|
||
f"'exiftool.exe' wurde im Ordner {SCRIPT_DIR} nicht gefunden!",
|
||
)
|
||
sys.exit(1)
|
||
|
||
window = EditFinderApp()
|
||
window.show()
|
||
sys.exit(app.exec()) |