import os import sys import re import subprocess import exiftool # High-Speed Bibliothek from datetime import datetime, timedelta, timezone try: from zoneinfo import ZoneInfo except ImportError: ZoneInfo = None # PyQt6 für die moderne Benutzeroberfläche from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLineEdit, QPushButton, QCheckBox, QProgressBar, QFileDialog, QMessageBox, QLabel, QGridLayout, QRadioButton, QDateEdit, QTableWidget, QTableWidgetItem, QHeaderView, QSplitter) from PyQt6.QtCore import QThread, pyqtSignal, QDate, Qt from PyQt6.QtGui import QColor, QFont # ========================================== # HILFSFUNKTIONEN # ========================================== def sicheres_datum(j, m, t, std=12, min_td=0, sek=0): """Fängt fehlerhafte Zeiten ab.""" try: s_std = min(23, int(std)) s_min = min(59, int(min_td)) s_sek = min(59, int(sek)) return datetime(int(j), int(m), int(t), s_std, s_min, s_sek) except ValueError: # Gibt None zurück, damit fehlerhafte Daten (z. B. 61. Tag) verworfen werden return None def utc_zu_lokalzeit_deutschland(dt_utc): """Konvertiert UTC-Zeit (Google Pixel) in deutsche Lokalzeit (MEZ / MESZ) ohne externe Libs.""" if dt_utc is None: return None year = dt_utc.year # Letzter Sonntag im März (Sommerzeit Start) mar_last_sun = 31 - (datetime(year, 3, 31).weekday() + 1) % 7 # Letzter Sonntag im Oktober (Sommerzeit Ende) oct_last_sun = 31 - (datetime(year, 10, 31).weekday() + 1) % 7 sommerzeit_start = datetime(year, 3, mar_last_sun, 1, 0, 0) # 01:00 UTC sommerzeit_ende = datetime(year, 10, oct_last_sun, 1, 0, 0) # 01:00 UTC if sommerzeit_start <= dt_utc < sommerzeit_ende: return dt_utc + timedelta(hours=2) # MESZ (UTC+2) else: return dt_utc + timedelta(hours=1) # MEZ (UTC+1) # ========================================== # BACKGROUND WORKER (QThread) # ========================================== class WorkerThread(QThread): # Signale für die Live-Aktualisierung der GUI-Tabelle table_signal = pyqtSignal(str, str, str, str, str) # Dateiname, Status, Alt, Neu, Bemerkung progress_signal = pyqtSignal(int) finished_signal = pyqtSignal(str) error_signal = pyqtSignal(str) # Signal zur Übertragung der Zähler an die GUI-Statistik stats_signal = pyqtSignal(int, int, int, int, int, int, int) # gesamt, exif, win, skip, ign, unmoeglich, fehler def __init__(self, ordner, exiftool, log_dir, unterordner, modus, festes_datum, dry_run, rename_files, fallback_folder, excl_prefixes, excl_exts, dateien_liste=None): super().__init__() self.ordner_pfad = ordner self.exiftool_pfad = exiftool self.log_ordner_pfad = log_dir self.unterordner_einbeziehen = unterordner self.modus = modus self.festes_datum = festes_datum self.dry_run = dry_run self.rename_files = rename_files self.fallback_folder = fallback_folder self.excl_prefixes = excl_prefixes self.excl_exts = excl_exts self.log_datei_pfad = "" self.dateien_liste = dateien_liste # NEU: Liste der reingezogenen Dateien def schreibe_log(self, text): """Schreibt eine Zeile sauber in die Log-Datei (ohne Steuerzeichen wie \\r).""" # In der GUI feuern wir das Signal an die Konsole (falls vorhanden) und schreiben parallel in die Datei if self.log_datei_pfad: try: clean_text = text.replace("\r", "") with open(self.log_datei_pfad, "a", encoding="utf-8") as lf: lf.write(clean_text + "\n") except Exception: pass def do_rename(self, filepath, dateiname, prefix, neues_datum): """Übernimmt das Präfix und benennt die Datei nach dem Schema YYYYMMDD_HHMMSS um (Kollisionsgeschützt).""" if not self.rename_files: return filepath, dateiname, "" ext = os.path.splitext(dateiname)[1].lower() neu_name = f"{prefix}_{neues_datum.strftime('%Y%m%d_%H%M%S')}" neu_file = neu_name + ext # Sofortiger Abbruch, wenn der Name ohnehin schon perfekt ist if dateiname == neu_file: return filepath, dateiname, "" dir_name = os.path.dirname(filepath) neu_filepath = os.path.join(dir_name, neu_file) # Slashes normalisieren, um falsche "Umbenennungen" bei Netzlaufwerken zu verhindern safe_filepath = os.path.normcase(os.path.normpath(filepath)) # Kollisionsschutz: Wenn Datei exakt denselben Namen hätte (selbe Sekunde) c = 1 while os.path.exists(neu_filepath) and safe_filepath != os.path.normcase(os.path.normpath(neu_filepath)): neu_file = f"{neu_name}_{c}{ext}" neu_filepath = os.path.join(dir_name, neu_file) c += 1 if safe_filepath != os.path.normcase(os.path.normpath(neu_filepath)): if not self.dry_run: try: os.rename(filepath, neu_filepath) except Exception as e: return filepath, dateiname, f" | ❌ Rename-Fehler: {e}" return neu_filepath, neu_file, f" | 🔄 Umbenannt: {neu_file}" return filepath, dateiname, "" def run(self): try: # ========================================== # COUNTER (ZÄHLER) INITIALISIEREN # ========================================== stat_exif_neu = 0 # Echt neu geschriebene EXIF-Daten stat_windows_fix = 0 # Nur Windows-Datum korrigiert (EXIF war schon ok) stat_blitz_skip = 0 # Per Blitz-Skip komplett übersprungen stat_ignoriert = 0 # Kein Muster im Namen gefunden stat_jahr_unmoeglich = 0# Jahr vor 1970 oder nach 2038 stat_fehler = 0 # Technische Fehler (Datei gesperrt etc.) # Log-Dateiname automatisch aus dem Zielordner generieren ordner_name_aktuell = os.path.basename(self.ordner_pfad.strip("\\/")) or "Root" if not os.path.exists(self.log_ordner_pfad): try: os.makedirs(self.log_ordner_pfad, exist_ok=True) except: pass # Ignore if it fails if os.path.exists(self.log_ordner_pfad): self.log_datei_pfad = os.path.join(self.log_ordner_pfad, f"log_{ordner_name_aktuell}_{datetime.now().strftime('%H%M%S')}.txt") with open(self.log_datei_pfad, "w", encoding="utf-8") as f: f.write(f"=== REPARATUR-LOG FÜR ORDNER: {ordner_name_aktuell} ===\n") f.write(f"Ausgeführt am: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}\n") f.write(f"Speicherort dieses Logs: {self.log_datei_pfad}\n") f.write(f"Unterordner einbeziehen: {'JA' if self.unterordner_einbeziehen else 'NEIN (Nur Hauptordner)'}\n") f.write(f"Modus: {'FESTES DATUM' if self.modus == 'fixed' else 'AUTOMATISCH'}\n") f.write(f"Toleranz-Einstellung: Abweichungen <= 240 Sekunden werden ignoriert.\n") f.write(f"PXL-Behandlung: Google Pixel UTC-Zeitstempel werden automatisch in MEZ/MESZ umgerechnet.\n") f.write(f"DRY RUN AKTIV: {'JA (Vorschau)' if self.dry_run else 'NEIN (Scharfer Durchlauf)'}\n") f.write(f"UMBENENNUNG AKTIV: {'JA' if self.rename_files else 'NEIN'}\n") f.write(f"ORDNER-FALLBACK AKTIV: {'JA' if self.fallback_folder else 'NEIN'}\n") if self.fallback_folder: f.write(f" -> FALLBACK-AUSSCHLUSS PRÄFIXE: {', '.join(self.excl_prefixes) if self.excl_prefixes else 'Keine'}\n") f.write(f" -> FALLBACK-AUSSCHLUSS TYPEN: {', '.join(self.excl_exts) if self.excl_exts else 'Keine'}\n") f.write("-" * 100 + "\n\n") if self.dry_run: self.schreibe_log("⚠️ ACHTUNG: VORSCHAU (DRY RUN) IST AKTIV! Es werden KEINE Dateien verändert.\n") # ========================================== # PHASE 1: Alle Dateien finden und zählen # ========================================== self.schreibe_log("Schritt 1: Scanne nach Dateien...") alle_dateien = [] # NEU: Wenn Dateien per Drag & Drop reingezogen wurden, nimm diese direkt! if self.dateien_liste: alle_dateien = self.dateien_liste self.schreibe_log("-> Drag & Drop Modus aktiv: Verarbeite manuell hinzugefügte Dateien.") else: # ALT: Bisherige Ordner-Suche if os.path.exists(self.ordner_pfad): for root_dir, dirs, files in os.walk(self.ordner_pfad): if not self.unterordner_einbeziehen: dirs.clear() for dateiname in files: if dateiname.lower().endswith((".mp4", ".jpg", ".jpeg", ".png",".gif",".m2ts",".wmv")): alle_dateien.append(os.path.join(root_dir, dateiname)) gesamt_anzahl = len(alle_dateien) if gesamt_anzahl == 0: self.schreibe_log("Keine passenden Bilder oder Videos gefunden!") self.finished_signal.emit("Keine passenden Dateien gefunden.") return self.schreibe_log(f"-> {gesamt_anzahl} Dateien gefunden.") self.schreibe_log(f"-> Modus: {'Inklusive Unterordner' if self.unterordner_einbeziehen else 'NUR Hauptordner'}\n") self.schreibe_log("Schritt 2: Starte Metadaten-Reparatur (High-Speed Modus)...\n") # Statistik vor Beginn initialisieren self.stats_signal.emit(gesamt_anzahl, 0, 0, 0, 0, 0, 0) letzter_ordner = None # ========================================== # HIGHSPEED: ExifTool im Batch-Modus starten! # Bleibt für die gesamte Schleife im Hintergrund offen. # ========================================== if not os.path.exists(self.exiftool_pfad): self.error_signal.emit("ExifTool nicht gefunden! Bitte Pfad prüfen.") return with exiftool.ExifTool(executable=self.exiftool_pfad) as et: for index, filepath in enumerate(alle_dateien, start=1): dateiname = os.path.basename(filepath) ordner_name = os.path.basename(os.path.dirname(filepath)) neues_datum = None folder_fallback_used = False prozent = int((index / gesamt_anzahl) * 100) self.progress_signal.emit(prozent) fortschritt = f"[{index:3}/{gesamt_anzahl} | {prozent:3}%]" if ordner_name != letzter_ordner: trennlinie = f"\n{'#'*100}\n# ORDNER: {ordner_name}\n{'#'*100}" self.schreibe_log(trennlinie) # NEU: Wir senden einen speziellen Platzhalter für die Tabelle self.table_signal.emit(f"📁 {ordner_name}", "ORDNER", "", "", "") letzter_ordner = ordner_name try: mtime = os.path.getmtime(filepath) aktuelle_mtime = datetime.fromtimestamp(mtime).replace(microsecond=0) mtime_str = aktuelle_mtime.strftime('%d.%m.%Y %H:%M:%S') except Exception as e: self.table_signal.emit(dateiname, "FEHLER", "-", "-", f"Lese-Fehler: {e}") self.schreibe_log(f"{fortschritt} FEHLER beim Lesen der Datei-Zeit: {e} | {dateiname}") stat_fehler += 1 self.stats_signal.emit(gesamt_anzahl, stat_exif_neu, stat_windows_fix, stat_blitz_skip, stat_ignoriert, stat_jahr_unmoeglich, stat_fehler) continue # ========================================== # PRÄFIX EXTRAKTION (Smarte Geräte-Erkennung für Renamer) # ========================================== prefix = "" ext_check = os.path.splitext(dateiname)[1].lower() orig_up = dateiname.upper() if "PXL" in orig_up: prefix = "PXL" elif "DSC" in orig_up: prefix = "DSC" elif "DJI" in orig_up: prefix = "DJI" elif "XIV" in orig_up: prefix = "XIV" elif "WHATSAPP" in orig_up or "WA" in orig_up: prefix = "WA" elif "SIGNAL" in orig_up: prefix = "Signal" elif "SCREENSHOT" in orig_up: prefix = "Screenshot" elif "GOPR" in orig_up or "GH" in orig_up: prefix = "GoPro" elif "WP" in orig_up: prefix = "WP" elif orig_up.startswith("IMG"): prefix = "IMG" elif orig_up.startswith("VID"): prefix = "VID" else: prefix = "VID" if ext_check in ['.mp4', '.mov', '.wmv', '.m2ts'] else "IMG" # ========================================== # SOFORTIGER ABBRUCH: GLOBALE BLACKLIST # ========================================== if prefix.upper() in self.excl_prefixes or ext_check in self.excl_exts: self.table_signal.emit(dateiname, "IGNORIERT", mtime_str, "-", "Blacklist (Präfix/Typ gesperrt)") self.schreibe_log(f"{fortschritt} IGNORIERT (Blacklist): {dateiname}") stat_ignoriert += 1 self.stats_signal.emit(gesamt_anzahl, stat_exif_neu, stat_windows_fix, stat_blitz_skip, stat_ignoriert, stat_jahr_unmoeglich, stat_fehler) continue # Bricht ab und springt direkt zur nächsten Datei! # ========================================== # DATUM ERMITTELN # ========================================== if self.modus == "fixed": neues_datum = datetime( year=self.festes_datum.year, month=self.festes_datum.month, day=self.festes_datum.day, hour=aktuelle_mtime.hour, minute=aktuelle_mtime.minute, second=aktuelle_mtime.second ) else: # REGULÄRE AUSDRÜCKE (MUSTER-ERKENNUNG ANHAND DES DATEINAMENS) match_pxl = re.search(r'PXL_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})', dateiname) # Muster 1: Standard-Zeitstempel (Vollständig mit Unterstrich) match_exakt = re.search(r'(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})', dateiname) # Muster 2: Reines Datum mit Trennpunkten match_datum_nur = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', dateiname) # Muster 3: DJI Alt (App-Export mit fehlerhafter Uhrzeit) match_dji_alt = re.search(r'_0_(\d{8})(\d{3,5})_photo', dateiname) # Muster 4: DSC Sony/Kamera-Format match_dsc = re.search(r'^(\d{8})-DSC', dateiname) # Muster 5: DJI Neu (Sauberer, 14-stelliger Zeitstempel) match_dji_neu = re.search(r'_(\d{14})_(photo|video)', dateiname) # Muster 6: Reines 8-stelliges Datum ohne Uhrzeit irgendwo im Namen match_acht_ziffern = re.search(r'(? kein Fallback else: safe_filepath = filepath.replace('\\', '/') path_parts = [p for p in safe_filepath.split('/') if p] for part in reversed(path_parts[:-1]): match_folder = re.search(r'(\d{4})[-_.](\d{2})[-_.](\d{2})', part) if not match_folder: match_folder = re.search(r'(? 2038: self.table_signal.emit(dateiname, "FEHLER", mtime_str, "-", f"Jahr {neues_datum.year} unmöglich") self.schreibe_log(f"{fortschritt} ÜBERSPRÜNGEN (Jahr {neues_datum.year} unmöglich): {dateiname}") stat_jahr_unmoeglich += 1 else: try: TOLERANZ_SEKUNDEN = 240 kann_exif_schreiben = not dateiname.lower().endswith((".wmv", ".m2ts", ".gif")) exif_passt = False vorher_datum_exif = "[LEER / Keine]" # ----------------------------------------------------------------- # HIGHSPEED-LESEN MIT PYEXIFTOOL + EXIF-TRUST-CHECK # ----------------------------------------------------------------- if kann_exif_schreiben: # NEU: Wir fragen nach DateTimeOriginal UND CreateDate (für Videos!) raw_exif_output = et.execute("-m", "-DateTimeOriginal", "-CreateDate", "-s3", filepath).strip() if raw_exif_output: # Hat eine Datei beide Tags, liefert ExifTool zwei Zeilen. Wir nehmen die erste. vorher_datum_exif = raw_exif_output.split('\n')[0].strip() try: # Robuster Regex-Filter für das reine Datum match_exif = re.search(r'(\d{4}:\d{2}:\d{2} \d{2}:\d{2}:\d{2})', vorher_datum_exif) if match_exif: clean_exif_str = match_exif.group(1) # Überschreibt den unsauberen String, damit "QuickTime" aus der Tabelle verschwindet vorher_datum_exif = clean_exif_str exif_dt = datetime.strptime(clean_exif_str, '%Y:%m:%d %H:%M:%S') # WICHTIGER EXIF-TRUST-CHECK if exif_dt.date() == neues_datum.date(): exif_passt = True neues_datum = exif_dt folder_fallback_used = False # Versteckt den Fallback-Hinweis elif abs((exif_dt - neues_datum).total_seconds()) <= TOLERANZ_SEKUNDEN: exif_passt = True folder_fallback_used = False # Versteckt den Fallback-Hinweis except ValueError: pass # Jetzt erst formatieren, nachdem der Trust-Check das Datum evtl. korrigiert hat exif_datum = neues_datum.strftime('%Y:%m:%d %H:%M:%S') win_time_diff = abs((aktuelle_mtime - neues_datum).total_seconds()) display_alt = vorher_datum_exif if kann_exif_schreiben else mtime_str fallback_msg = " [Ordner-Fallback]" if folder_fallback_used else "" # ----------------------------------------------------------------- # FALL A: SKIP # ----------------------------------------------------------------- if exif_passt and (win_time_diff <= TOLERANZ_SEKUNDEN): filepath, dateiname, ren_msg = self.do_rename(filepath, dateiname, prefix, neues_datum) self.table_signal.emit(dateiname, "SKIP", display_alt, exif_datum, f"Bereits exakt{fallback_msg}{ren_msg}") self.schreibe_log(f"{fortschritt} BLITZ-SKIP -> {exif_datum} | {dateiname}{fallback_msg}{ren_msg}") stat_blitz_skip += 1 # ----------------------------------------------------------------- # FALL B: FIX WIN-TIME ONLY # ----------------------------------------------------------------- elif exif_passt or not kann_exif_schreiben: filepath, dateiname, ren_msg = self.do_rename(filepath, dateiname, prefix, neues_datum) if not self.dry_run: timestamp = neues_datum.timestamp() os.utime(filepath, (timestamp, timestamp)) status_text = "DRY RUN (FIX)" if self.dry_run else "FIX (WIN)" self.table_signal.emit(dateiname, status_text, display_alt, exif_datum, f"Nur Windows-Zeit{fallback_msg}{ren_msg}") aktion = "[VORSCHAU] WÜRDE WIN-ZEIT FIXEN" if self.dry_run else "WINDOWS-TIME FIX" self.schreibe_log(f"{fortschritt} {aktion} -> {exif_datum} | Vorher Win-MTime: {mtime_str} | {dateiname}{fallback_msg}{ren_msg}") stat_windows_fix += 1 # ----------------------------------------------------------------- # FALL C: HIGHSPEED-SCHREIBEN MIT PYEXIFTOOL # ----------------------------------------------------------------- else: filepath, dateiname, ren_msg = self.do_rename(filepath, dateiname, prefix, neues_datum) if not self.dry_run: # execute feuert den Befehl direkt in die bereits offene Instanz! output = et.execute("-m", f"-AllDates={exif_datum}", "-overwrite_original_in_place", filepath) timestamp = neues_datum.timestamp() os.utime(filepath, (timestamp, timestamp)) if "unchanged" in output: self.table_signal.emit(dateiname, "FIX (WIN)", display_alt, exif_datum, f"EXIF war bereits OK{fallback_msg}{ren_msg}") self.schreibe_log(f"{fortschritt} WINDOWS-TIME FIX -> {exif_datum} | Vorher EXIF: {vorher_datum_exif} | {dateiname}{fallback_msg}{ren_msg}") stat_windows_fix += 1 else: self.table_signal.emit(dateiname, "ERFOLG", display_alt, exif_datum, f"EXIF neu{fallback_msg}{ren_msg}") self.schreibe_log(f"{fortschritt} ERFOLG (EXIF neu) -> {exif_datum} | Vorher EXIF: {vorher_datum_exif} | {dateiname}{fallback_msg}{ren_msg}") stat_exif_neu += 1 else: self.table_signal.emit(dateiname, "VORSCHAU", display_alt, exif_datum, f"Würde EXIF schreiben{fallback_msg}{ren_msg}") self.schreibe_log(f"{fortschritt} [VORSCHAU] WÜRDE EXIF SCHREIBEN -> {exif_datum} | Vorher EXIF: {vorher_datum_exif} | {dateiname}{fallback_msg}{ren_msg}") stat_exif_neu += 1 except Exception as e: self.table_signal.emit(dateiname, "FEHLER", "-", "-", f"Fehler: {str(e)[:30]}") self.schreibe_log(f"{fortschritt} FEHLER bei {dateiname}: {e}") stat_fehler += 1 else: self.table_signal.emit(dateiname, "IGNORIERT", mtime_str, "-", "Kein Datum im Dateinamen/Ordner gefunden") self.schreibe_log(f"{fortschritt} IGNORIERT (Kein gültiges Datum im Namen): {dateiname}") stat_ignoriert += 1 # Live-Statistik nach jeder Datei updaten self.stats_signal.emit(gesamt_anzahl, stat_exif_neu, stat_windows_fix, stat_blitz_skip, stat_ignoriert, stat_jahr_unmoeglich, stat_fehler) # ========================================== # ZUSAMMENFASSUNG (DASHBOARD) # ========================================== ZUSAMMENFASSUNG = f""" {"="*100} ZUSAMMENFASSUNG FÜR ORDNER: {ordner_name_aktuell} {'(VORSCHAU - KEINE DATEIEN VERÄNDERT)' if self.dry_run else ''} {"-"*100} Modus: {'FESTES DATUM ERZWUNGEN' if self.modus == 'fixed' else 'Automatisch (Regex)'} Unterordner einbeziehen: {'JA' if self.unterordner_einbeziehen else 'NEIN (Nur Hauptordner)'} Gesamt gescannte Dateien: {gesamt_anzahl} [+] EXIF {'würde ' if self.dry_run else ''}neu geschrieben: {stat_exif_neu} [*] Nur Windows-Zeit {'würde ' if self.dry_run else ''}fixiert: {stat_windows_fix} [>] BLITZ-SKIP (Bereits korrekt): {stat_blitz_skip} [-] Ignoriert (Kein Datum im Name):{stat_ignoriert} [!] Übersprungen (Jahr unmöglich): {stat_jahr_unmoeglich} [x] Technische Fehler: {stat_fehler} {"="*100}""" self.schreibe_log(ZUSAMMENFASSUNG) self.finished_signal.emit("Vorschau erfolgreich beendet!" if self.dry_run else "Änderungen erfolgreich angewendet!") except Exception as e: self.error_signal.emit(str(e)) # ========================================== # HAUPTFENSTER (GUI) # ========================================== class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Metadaten Reparatur Tool") self.resize(1400, 750) self.worker = None # NEU: Drag & Drop fürs ganze Fenster aktivieren & Speicherliste anlegen self.setAcceptDrops(True) self.dropped_files = [] self.setup_ui() def setup_ui(self): central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QVBoxLayout(central_widget) main_layout.setContentsMargins(5, 5, 5, 5) self.splitter = QSplitter(Qt.Orientation.Horizontal) main_layout.addWidget(self.splitter) # --- LINKE SEITE (Einstellungen) --- left_panel = QWidget() left_layout = QVBoxLayout(left_panel) left_layout.setContentsMargins(5, 5, 10, 5) # 1. PFADE path_box = QGroupBox("Pfade & Zielordner") grid = QGridLayout() self.input_ordner = QLineEdit(r"\\192.168.180.78\fotos\Still to komp\2025") self.input_ordner.textChanged.connect(self.check_ordner_reset) script_dir = os.path.dirname(os.path.abspath(__file__)) exe_path = os.path.join(script_dir, "exiftool-13.59_64", "exiftool-13.59_64", "exiftool.exe") self.input_exif = QLineEdit(exe_path) self.input_log = QLineEdit(r"C:\Users\janni\Documents\GitLab\metadaten-aus-namen\Logs") btn_ordner = QPushButton("...") btn_ordner.clicked.connect(self.browse_ordner) btn_exif = QPushButton("...") btn_exif.clicked.connect(self.browse_exif) btn_log = QPushButton("...") btn_log.clicked.connect(self.browse_log) grid.addWidget(QLabel("Zielordner:"), 0, 0) grid.addWidget(self.input_ordner, 0, 1) grid.addWidget(btn_ordner, 0, 2) grid.addWidget(QLabel("ExifTool:"), 1, 0) grid.addWidget(self.input_exif, 1, 1) grid.addWidget(btn_exif, 1, 2) grid.addWidget(QLabel("Log-Ordner:"), 2, 0) grid.addWidget(self.input_log, 2, 1) grid.addWidget(btn_log, 2, 2) path_box.setLayout(grid) left_layout.addWidget(path_box) # 2. MODUS mode_box = QGroupBox("Verarbeitungs-Modus") mode_layout = QVBoxLayout() self.radio_auto = QRadioButton("Datum automatisch aus Dateinamen (Regex)") self.radio_auto.setChecked(True) self.radio_fixed = QRadioButton("Festes Datum erzwingen:") self.date_input = QDateEdit(QDate.currentDate()) self.date_input.setDisplayFormat("dd.MM.yyyy") self.date_input.setCalendarPopup(True) self.date_input.setEnabled(False) self.radio_fixed.toggled.connect(self.date_input.setEnabled) mode_layout.addWidget(self.radio_auto) fixed_layout = QHBoxLayout() fixed_layout.addWidget(self.radio_fixed) fixed_layout.addWidget(self.date_input) fixed_layout.addStretch() mode_layout.addLayout(fixed_layout) mode_box.setLayout(mode_layout) left_layout.addWidget(mode_box) # 3. OPTIONEN options_box = QGroupBox("Optionen") opt_layout = QVBoxLayout() self.check_unterordner = QCheckBox("Unterordner einbeziehen") self.check_unterordner.setChecked(True) self.check_rename = QCheckBox("Dateien einheitlich umbenennen (Präfix_YYYYMMDD_HHMMSS)") self.check_rename.setChecked(False) self.check_folder_fallback = QCheckBox("Fallback: Datum aus Ordnernamen lesen (z.B. YYYY-MM-DD)") self.check_folder_fallback.setChecked(True) fallback_filter_layout = QHBoxLayout() fallback_filter_layout.setContentsMargins(25, 0, 0, 10) fallback_filter_layout.addWidget(QLabel("Generell ignorieren - Präfixe:")) self.input_excl_prefixes = QLineEdit("DSC,XIV") self.input_excl_prefixes.setPlaceholderText("z.B. DSC, IMG") fallback_filter_layout.addWidget(self.input_excl_prefixes) fallback_filter_layout.addWidget(QLabel("Dateitypen:")) self.input_excl_exts = QLineEdit(".m2ts") self.input_excl_exts.setPlaceholderText("z.B. .m2ts, .gif") fallback_filter_layout.addWidget(self.input_excl_exts) self.check_folder_fallback.toggled.connect(self.input_excl_prefixes.setEnabled) self.check_folder_fallback.toggled.connect(self.input_excl_exts.setEnabled) opt_layout.addWidget(self.check_unterordner) opt_layout.addWidget(self.check_folder_fallback) opt_layout.addLayout(fallback_filter_layout) opt_layout.addWidget(self.check_rename) options_box.setLayout(opt_layout) left_layout.addWidget(options_box) # ========================================== # 4. LIVE-STATISTIK # ========================================== stats_box = QGroupBox("Live-Statistik") stats_layout = QGridLayout() self.lbl_stat_gesamt = QLabel("0") self.lbl_stat_exif = QLabel("0") self.lbl_stat_win = QLabel("0") self.lbl_stat_skip = QLabel("0") self.lbl_stat_ign = QLabel("0") self.lbl_stat_err = QLabel("0") font_bold = QFont() font_bold.setBold(True) for lbl in [self.lbl_stat_gesamt, self.lbl_stat_exif, self.lbl_stat_win, self.lbl_stat_skip, self.lbl_stat_ign, self.lbl_stat_err]: lbl.setFont(font_bold) self.lbl_stat_exif.setStyleSheet("color: #d28e00;") self.lbl_stat_win.setStyleSheet("color: #d28e00;") self.lbl_stat_skip.setStyleSheet("color: gray;") self.lbl_stat_ign.setStyleSheet("color: gray;") self.lbl_stat_err.setStyleSheet("color: red;") stats_layout.addWidget(QLabel("Gesamt gescannte Dateien:"), 0, 0) stats_layout.addWidget(self.lbl_stat_gesamt, 0, 1) stats_layout.addWidget(QLabel("[+] EXIF geschrieben / Vorschau:"), 1, 0) stats_layout.addWidget(self.lbl_stat_exif, 1, 1) stats_layout.addWidget(QLabel("[*] Nur Windows-Zeit fixiert:"), 2, 0) stats_layout.addWidget(self.lbl_stat_win, 2, 1) stats_layout.addWidget(QLabel("[>] BLITZ-SKIP (Bereits exakt):"), 3, 0) stats_layout.addWidget(self.lbl_stat_skip, 3, 1) stats_layout.addWidget(QLabel("[-] Ignoriert (Kein Datum gefunden):"), 4, 0) stats_layout.addWidget(self.lbl_stat_ign, 4, 1) stats_layout.addWidget(QLabel("[x] Fehler / Unmögliches Jahr:"), 5, 0) stats_layout.addWidget(self.lbl_stat_err, 5, 1) stats_box.setLayout(stats_layout) left_layout.addWidget(stats_box) # 5. START BUTTONS & PROGRESS left_layout.addStretch() button_layout = QHBoxLayout() self.btn_preview = QPushButton("🔍 Vorschau (Simulation)") self.btn_preview.setMinimumHeight(45) font_prev = self.btn_preview.font() font_prev.setBold(True) self.btn_preview.setFont(font_prev) self.btn_preview.clicked.connect(lambda: self.start_processing(is_dry_run=True)) self.btn_apply = QPushButton("⚠️ Änderungen Anwenden") self.btn_apply.setMinimumHeight(45) font_app = self.btn_apply.font() font_app.setBold(True) self.btn_apply.setFont(font_app) self.btn_apply.setStyleSheet("background-color: #b91d47; color: white;") self.btn_apply.clicked.connect(lambda: self.start_processing(is_dry_run=False)) button_layout.addWidget(self.btn_preview) button_layout.addWidget(self.btn_apply) left_layout.addLayout(button_layout) self.progress_bar = QProgressBar() self.progress_bar.setValue(0) self.progress_bar.setTextVisible(True) left_layout.addWidget(self.progress_bar) self.splitter.addWidget(left_panel) # --- RECHTE SEITE (Tabelle) --- right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(0, 5, 5, 5) # NEU: Kopfzeile über der Tabelle mit Titel und "Liste leeren"-Button table_top_layout = QHBoxLayout() lbl_table_title = QLabel("Dateiansicht / Drag & Drop Bereich") font_title = lbl_table_title.font() font_title.setBold(True) lbl_table_title.setFont(font_title) self.btn_clear_list = QPushButton("🗑️ Liste leeren") self.btn_clear_list.setStyleSheet("background-color: #f0f0f0; padding: 5px;") self.btn_clear_list.clicked.connect(self.clear_drag_drop_list) self.btn_clear_list.setEnabled(False) # Erst aktiv, wenn Dateien da sind table_top_layout.addWidget(lbl_table_title) table_top_layout.addStretch() table_top_layout.addWidget(self.btn_clear_list) right_layout.addLayout(table_top_layout) self.table = QTableWidget(0, 5) self.table.setHorizontalHeaderLabels(["Dateiname", "Status", "Altes Datum", "Neues Datum", "Bemerkung"]) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive) header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) header.setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) self.table.setColumnWidth(0, 320) self.table.setColumnWidth(1, 120) self.table.setColumnWidth(2, 160) self.table.setColumnWidth(3, 160) self.table.setSortingEnabled(True) self.table.setAlternatingRowColors(True) self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) right_layout.addWidget(self.table) self.splitter.addWidget(right_panel) self.splitter.setSizes([450, 950]) # --- FUNKTIONEN --- def check_ordner_reset(self): """Leert die Drag&Drop-Liste, wenn der User wieder einen normalen Ordnerpfad eintippt.""" if self.input_ordner.text() != "DRAG & DROP MODUS (Manuelle Auswahl)": self.dropped_files = [] self.btn_clear_list.setEnabled(False) def dragEnterEvent(self, event): if event.mimeData().hasUrls(): event.accept() else: event.ignore() def dropEvent(self, event): urls = event.mimeData().urls() if not urls: return gueltige_endungen = (".mp4", ".jpg", ".jpeg", ".png", ".gif", ".m2ts", ".wmv") neue_dateien = [] for url in urls: pfad = url.toLocalFile() # Nur echte Dateien mit den richtigen Endungen akzeptieren if os.path.isfile(pfad) and pfad.lower().endswith(gueltige_endungen): neue_dateien.append(os.path.normpath(pfad)) if neue_dateien: # Damit check_ordner_reset nicht sofort unsere Liste löscht, # blockieren wir kurz das Signal des Textfeldes self.input_ordner.blockSignals(True) self.input_ordner.setText("DRAG & DROP MODUS (Manuelle Auswahl)") self.input_ordner.blockSignals(False) self.dropped_files.extend(neue_dateien) # Duplikate entfernen (falls man aus Versehen Dateien doppelt reinzieht) self.dropped_files = list(dict.fromkeys(self.dropped_files)) # Anzeige in der Tabelle leeren und vorbereiten self.table.setRowCount(0) for datei in self.dropped_files: self.add_table_row(os.path.basename(datei), "BEREIT", "-", "-", os.path.dirname(datei)) self.btn_clear_list.setEnabled(True) event.accept() def clear_drag_drop_list(self): """Leert die manuell per Drag & Drop hinzugefügten Dateien und setzt die GUI zurück.""" self.dropped_files = [] self.table.setRowCount(0) self.btn_clear_list.setEnabled(False) # Signal blockieren, damit check_ordner_reset nicht im Kreis läuft self.input_ordner.blockSignals(True) if self.input_ordner.text() == "DRAG & DROP MODUS (Manuelle Auswahl)": self.input_ordner.clear() self.input_ordner.blockSignals(False) # Setzt die Statistik zurück self.update_stats(0, 0, 0, 0, 0, 0, 0) def browse_ordner(self): folder = QFileDialog.getExistingDirectory(self, "Zielordner auswählen") if folder: self.input_ordner.setText(folder) def browse_exif(self): file, _ = QFileDialog.getOpenFileName(self, "exiftool.exe auswählen", "", "Executable (*.exe)") if file: self.input_exif.setText(file) def browse_log(self): folder = QFileDialog.getExistingDirectory(self, "Log-Ordner auswählen") if folder: self.input_log.setText(folder) def update_stats(self, gesamt, exif, win, skip, ign, unmoeglich, fehler): self.lbl_stat_gesamt.setText(str(gesamt)) self.lbl_stat_exif.setText(str(exif)) self.lbl_stat_win.setText(str(win)) self.lbl_stat_skip.setText(str(skip)) self.lbl_stat_ign.setText(str(ign)) self.lbl_stat_err.setText(str(unmoeglich + fehler)) def add_table_row(self, dateiname, status, alt, neu, bemerkung): row = self.table.rowCount() self.table.insertRow(row) # ========================================== # NEU: Ordner-Platzhalter formatieren # ========================================== if status == "ORDNER": for col in range(5): text = dateiname if col == 0 else "" item = QTableWidgetItem(text) font = item.font() font.setBold(True) item.setFont(font) item.setForeground(QColor("#5aa5ff")) # Hellblaues Highlight item.setBackground(QColor(100, 100, 100, 40)) # Dezenter Hintergrund für die ganze Zeile self.table.setItem(row, col, item) self.table.scrollToBottom() return self.table.setItem(row, 0, QTableWidgetItem(dateiname)) status_item = QTableWidgetItem(status) font = status_item.font() font.setBold(True) status_item.setFont(font) if "ERFOLG" in status or "FIX" in status: status_item.setForeground(QColor("green")) elif "FEHLER" in status or "WARNUNG" in status: status_item.setForeground(QColor("red")) elif "SKIP" in status or "IGNORIERT" in status: status_item.setForeground(QColor("gray")) elif "VORSCHAU" in status or "DRY RUN" in status: status_item.setForeground(QColor("#d28e00")) elif status == "BEREIT": status_item.setForeground(QColor("#5aa5ff")) # Hellblau für bereit self.table.setItem(row, 1, status_item) self.table.setItem(row, 2, QTableWidgetItem(alt)) self.table.setItem(row, 3, QTableWidgetItem(neu)) self.table.setItem(row, 4, QTableWidgetItem(bemerkung)) self.table.scrollToBottom() def start_processing(self, is_dry_run=True): gewaehlter_modus = "fixed" if self.radio_fixed.isChecked() else "auto" festes_date = self.date_input.date().toPyDate() is_rename_active = self.check_rename.isChecked() is_folder_fallback_active = self.check_folder_fallback.isChecked() excl_pref = [p.strip().upper() for p in self.input_excl_prefixes.text().split(",") if p.strip()] excl_ext = [e.strip().lower() for e in self.input_excl_exts.text().split(",") if e.strip()] if not is_dry_run: antwort = QMessageBox.question( self, "Scharfer Modus", "Achtung: Dateien werden nun physisch überschrieben und umbenannt.\n\nHast du die Vorschau geprüft und bist dir sicher?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if antwort == QMessageBox.StandardButton.No: return # Bei Neustart die Live-Farben für die Statistik an den Modus anpassen if not is_dry_run: self.lbl_stat_exif.setStyleSheet("color: green;") self.lbl_stat_win.setStyleSheet("color: green;") else: self.lbl_stat_exif.setStyleSheet("color: #d28e00;") self.lbl_stat_win.setStyleSheet("color: #d28e00;") self.btn_preview.setEnabled(False) self.btn_apply.setEnabled(False) self.progress_bar.setValue(0) self.table.setSortingEnabled(False) self.table.setRowCount(0) # Statistik auf 0 setzen self.update_stats(0, 0, 0, 0, 0, 0, 0) # Prüfen, ob wir die manuellen Dateien oder den Ordner nutzen files_to_process = self.dropped_files if self.dropped_files else None self.worker = WorkerThread( ordner=self.input_ordner.text(), exiftool=self.input_exif.text(), log_dir=self.input_log.text(), unterordner=self.check_unterordner.isChecked(), modus=gewaehlter_modus, festes_datum=festes_date, dry_run=is_dry_run, rename_files=is_rename_active, fallback_folder=is_folder_fallback_active, excl_prefixes=excl_pref, excl_exts=excl_ext, dateien_liste=files_to_process # NEU: Liste übergeben ) self.worker.table_signal.connect(self.add_table_row) self.worker.progress_signal.connect(self.progress_bar.setValue) self.worker.stats_signal.connect(self.update_stats) self.worker.finished_signal.connect(self.process_finished) self.worker.error_signal.connect(self.process_error) self.worker.start() def process_finished(self, msg): self.btn_preview.setEnabled(True) self.btn_apply.setEnabled(True) self.table.setSortingEnabled(True) QMessageBox.information(self, "Fertig", msg) def process_error(self, err_msg): self.btn_preview.setEnabled(True) self.btn_apply.setEnabled(True) self.table.setSortingEnabled(True) QMessageBox.critical(self, "Kritischer Fehler", err_msg) if __name__ == "__main__": app = QApplication(sys.argv) app.setStyle("Fusion") window = MainWindow() window.show() sys.exit(app.exec())