1035 lines
No EOL
54 KiB
Python
1035 lines
No EOL
54 KiB
Python
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'(?<!\d)(\d{4})(\d{2})(\d{2})(?!\d)', dateiname)
|
|
|
|
# Muster 7: Datum mit Bindestrichen ohne Uhrzeit
|
|
match_bindestrich = re.search(r'(\d{4})-(\d{2})-(\d{2})', dateiname)
|
|
|
|
# Muster 8: HwVideoEditor Format
|
|
match_hw = re.search(r'HwVideoEditor_(\d{4})_(\d{2})_(\d{2})_(\d{2})(\d{2})(\d{2})', dateiname)
|
|
|
|
# Muster 8b: LightCut Format
|
|
match_lv = re.search(r'lv_0_(\d{14})', dateiname)
|
|
|
|
# Muster 9: Reines Datum mit Unterstrichen
|
|
match_unterstrich = re.search(r'(\d{4})_(\d{2})_(\d{2})', dateiname)
|
|
|
|
# Muster 11: WhatsApp Format
|
|
match_whatsapp = re.search(r'(\d{4})-(\d{2})-(\d{2}) at (\d{2})\.(\d{2})\.(\d{2})', dateiname)
|
|
|
|
# Muster 12: Samsung / Android Screenshots
|
|
match_samsung = re.search(r'(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})', dateiname)
|
|
|
|
# Muster 13: Signal Messenger
|
|
match_signal = re.search(r'signal-(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})', dateiname)
|
|
|
|
# Muster 14: Deutsches Datumsformat (Tag zuerst!)
|
|
match_german = re.search(r'(?<!\d)(\d{2})\.(\d{2})\.(\d{4})(?!\d)', dateiname)
|
|
|
|
# Muster 16: Durchgehende Bindestriche
|
|
match_bindestrich_voll = re.search(r'(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})', dateiname)
|
|
|
|
# Muster 18: Windows Phone
|
|
match_wp = re.search(r'WP_(\d{4})(\d{2})(\d{2})_(\d{2})_(\d{2})_(\d{2})', dateiname)
|
|
|
|
# PRIORIERTES FORMAT-MATCHING
|
|
if match_pxl:
|
|
j, m, t, std, min_td, sek = match_pxl.groups()
|
|
neues_datum = utc_zu_lokalzeit_deutschland(sicheres_datum(j, m, t, std, min_td, sek))
|
|
elif match_hw:
|
|
j, m, t, std, min_td, sek = match_hw.groups()
|
|
neues_datum = sicheres_datum(j, m, t, std, min_td, sek)
|
|
elif match_lv:
|
|
ts = match_lv.group(1)
|
|
neues_datum = sicheres_datum(ts[0:4], ts[4:6], ts[6:8], ts[8:10], ts[10:12], ts[12:14])
|
|
elif match_dji_neu:
|
|
ts = match_dji_neu.group(1)
|
|
neues_datum = sicheres_datum(ts[0:4], ts[4:6], ts[6:8], ts[8:10], ts[10:12], ts[12:14])
|
|
elif match_bindestrich_voll:
|
|
j, m, t, std, min_td, sek = match_bindestrich_voll.groups()
|
|
neues_datum = sicheres_datum(j, m, t, std, min_td, sek)
|
|
elif match_whatsapp:
|
|
j, m, t, std, min_td, sek = match_whatsapp.groups()
|
|
neues_datum = sicheres_datum(j, m, t, std, min_td, sek)
|
|
elif match_samsung:
|
|
j, m, t, std, min_td, sek = match_samsung.groups()
|
|
neues_datum = sicheres_datum(j, m, t, std, min_td, sek)
|
|
elif match_signal:
|
|
j, m, t, std, min_td, sek = match_signal.groups()
|
|
neues_datum = sicheres_datum(j, m, t, std, min_td, sek)
|
|
elif match_exakt:
|
|
j, m, t, std, min_td, sek = match_exakt.groups()
|
|
neues_datum = sicheres_datum(j, m, t, std, min_td, sek)
|
|
elif match_dji_alt:
|
|
datum_str = match_dji_alt.group(1)
|
|
zeit_str = match_dji_alt.group(2)
|
|
j, m, t = datum_str[0:4], datum_str[4:6], datum_str[6:8]
|
|
try:
|
|
sek = int(zeit_str[-2:])
|
|
rest = zeit_str[:-2]
|
|
if len(rest) == 4: std, min_td = int(rest[0:2]), int(rest[2:4])
|
|
elif len(rest) == 3: std, min_td = (int(rest[0:2]), int(rest[2])) if int(rest[0:2]) <= 23 else (int(rest[0]), int(rest[1:3]))
|
|
else: std, min_td = 12, 0
|
|
neues_datum = sicheres_datum(j, m, t, std, min_td, sek)
|
|
except:
|
|
neues_datum = sicheres_datum(j, m, t)
|
|
|
|
# --- Formate ohne Uhrzeit (Mit Uhrzeit-Rettung) ---
|
|
elif match_dsc:
|
|
j, m, t = match_dsc.group(1)[0:4], match_dsc.group(1)[4:6], match_dsc.group(1)[6:8]
|
|
if aktuelle_mtime.year == int(j) and aktuelle_mtime.month == int(m) and aktuelle_mtime.day == int(t):
|
|
neues_datum = sicheres_datum(j, m, t, aktuelle_mtime.hour, aktuelle_mtime.minute, aktuelle_mtime.second)
|
|
else: neues_datum = sicheres_datum(j, m, t)
|
|
elif match_datum_nur:
|
|
j, m, t = match_datum_nur.groups()
|
|
if aktuelle_mtime.year == int(j) and aktuelle_mtime.month == int(m) and aktuelle_mtime.day == int(t):
|
|
neues_datum = sicheres_datum(j, m, t, aktuelle_mtime.hour, aktuelle_mtime.minute, aktuelle_mtime.second)
|
|
else: neues_datum = sicheres_datum(j, m, t)
|
|
elif match_german:
|
|
t, m, j = match_german.groups()
|
|
if aktuelle_mtime.year == int(j) and aktuelle_mtime.month == int(m) and aktuelle_mtime.day == int(t):
|
|
neues_datum = sicheres_datum(j, m, t, aktuelle_mtime.hour, aktuelle_mtime.minute, aktuelle_mtime.second)
|
|
else: neues_datum = sicheres_datum(j, m, t)
|
|
elif match_acht_ziffern:
|
|
j, m, t = match_acht_ziffern.groups()
|
|
if aktuelle_mtime.year == int(j) and aktuelle_mtime.month == int(m) and aktuelle_mtime.day == int(t):
|
|
neues_datum = sicheres_datum(j, m, t, aktuelle_mtime.hour, aktuelle_mtime.minute, aktuelle_mtime.second)
|
|
else: neues_datum = sicheres_datum(j, m, t)
|
|
elif match_bindestrich:
|
|
j, m, t = match_bindestrich.groups()
|
|
if aktuelle_mtime.year == int(j) and aktuelle_mtime.month == int(m) and aktuelle_mtime.day == int(t):
|
|
neues_datum = sicheres_datum(j, m, t, aktuelle_mtime.hour, aktuelle_mtime.minute, aktuelle_mtime.second)
|
|
else: neues_datum = sicheres_datum(j, m, t)
|
|
elif match_unterstrich:
|
|
j, m, t = match_unterstrich.groups()
|
|
if aktuelle_mtime.year == int(j) and aktuelle_mtime.month == int(m) and aktuelle_mtime.day == int(t):
|
|
neues_datum = sicheres_datum(j, m, t, aktuelle_mtime.hour, aktuelle_mtime.minute, aktuelle_mtime.second)
|
|
else: neues_datum = sicheres_datum(j, m, t)
|
|
elif match_wp:
|
|
j, m, t, std, min_td, sek = match_wp.groups()
|
|
neues_datum = sicheres_datum(j, m, t, std, min_td, sek)
|
|
|
|
# ==========================================
|
|
# ORDNER-FALLBACK (Tiefensuche + Filter)
|
|
# ==========================================
|
|
if neues_datum is None and self.fallback_folder:
|
|
if prefix.upper() in self.excl_prefixes or ext_check in self.excl_exts:
|
|
pass # Auf Schwarzer Liste -> 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'(?<!\d)(\d{4})(\d{2})(\d{2})(?!\d)', part)
|
|
|
|
if match_folder:
|
|
j, m, t = match_folder.groups()
|
|
nd = sicheres_datum(j, m, t, aktuelle_mtime.hour, aktuelle_mtime.minute, aktuelle_mtime.second)
|
|
if nd:
|
|
neues_datum = nd
|
|
folder_fallback_used = True
|
|
break
|
|
|
|
# ==========================================
|
|
# Datumsänderung prüfen & anwenden
|
|
# ==========================================
|
|
if neues_datum:
|
|
if neues_datum.year < 1970 or neues_datum.year > 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()) |