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())