diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20327ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# OS +.DS_Store +Thumbs.db + +# Python +__pycache__/ +*.py[cod] +*.pyo +*.egg-info/ +dist/ +build/ +*.spec + +# Virtual Environment +.venv/ +venv/ +env/ + +# IDE +.vscode/ +.idea/ + +# App Data +Cover/* +settings.json +history.json diff --git a/.gitnore b/.gitnore deleted file mode 100644 index 1a9ddb0..0000000 --- a/.gitnore +++ /dev/null @@ -1,3 +0,0 @@ -.DS_Store -Cover/* -App \ No newline at end of file diff --git a/README.md b/README.md index 15c51eb..912ac23 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,95 @@ -# đŸŽ” WAV zu MP3 Konverter - -Ein einfaches, benutzerfreundliches Python-Tool mit grafischer OberflĂ€che (Tkinter), das WAV-Dateien in MP3-Dateien konvertiert. Es unterstĂŒtzt automatische Metadaten-Erstellung (KĂŒnstler, Album, Titel, Tracknummer) und fĂŒgt sogar ein Cover-Bild ein. Alle Dateien werden automatisch alphabetisch sortiert. - ---- - -## ✅ Features - -- 📂 Mehrere WAV-Dateien auswĂ€hlen -- 📝 Standard-Metadaten definieren (KĂŒnstler, Album, Cover) -- 🧠 Vorschau & Bearbeitung der Metadaten vor der Konvertierung -- 🔄 Automatische Sortierung nach Dateinamen -- đŸ–Œïž Cover-Bild einfĂŒgen -- đŸ’Ÿ Einstellungen werden gespeichert (Metadaten & letzter Export-Ordner) -- 📊 Fortschrittsbalken wĂ€hrend der Konvertierung - ---- - -## đŸ› ïž Setup - -# Automatisches Setup starten -Windows-Nutzer können einfach ````setup.bat````` ausfĂŒhren – es installiert: -- Python (falls nicht vorhanden) -- Virtuelle Umgebung -- Alle benötigten Python-Pakete (pydub, mutagen) -- FFmpeg (wird automatisch heruntergeladen & eingebunden) - -# Alternativ: Manuell installieren -1. Stelle sicher, dass Python 3.11 oder neuer installiert ist. -2. Virtuelle Umgebung erstellen und aktivieren: -````python -m venv .venv -.venv\Scripts\activate```` - -3. AbhĂ€ngigkeiten installieren: -`````pip install pydub mutagen```` - -4. FFmpeg herunterladen und den bin-Ordner zur PATH-Umgebungsvariable hinzufĂŒgen. - -# 🚀 Anwendung starten -1. main.py ausfĂŒhren: -````.venv\Scripts\python.exe main.py```` - -2. WAV-Dateien auswĂ€hlen und konvertieren. - - -#📁 Projektstruktur - -📩 wav-to-mp3-converter -├── setup.bat # Batch-Datei zum Starten der Installation -├── setup.ps1 # PowerShell-Setupscript -├── settings.json # Gespeicherte Einstellungen (automatisch erzeugt) -├── main.py # Hauptprogramm mit GUI -└── README.md # Diese Datei - -#â„č Hinweise -Nur .wav-Dateien werden akzeptiert. - -Cover muss eine .jpg- oder .jpeg-Datei sein. - -Export erfolgt im .mp3-Format, benannt nach dem Originaldateinamen. - -Metadaten können vor jeder Konvertierung ĂŒberprĂŒft und angepasst werden. \ No newline at end of file +# fcco Converter V2 + +WAV-zu-MP3-Konverter mit grafischer BenutzeroberflĂ€che. UnterstĂŒtzt automatische Metadaten-Erstellung (KĂŒnstler, Album, Titel, Tracknummer), Cover-Bilder und alphabetische Sortierung. LĂ€uft auf **Windows** und **macOS**. + +--- + +## Features + +- Mehrere WAV-Dateien oder ganze Ordner (mit Unterordnern) auswĂ€hlen +- Metadaten definieren und bearbeiten (KĂŒnstler, Album, Jahr, Cover) +- Automatische Sortierung nach Dateinamen +- Cover-Bild einfĂŒgen (JPG/PNG) +- Einstellungen werden dauerhaft gespeichert +- Fortschrittsanzeige pro Datei und gesamt +- Automatisches Update direkt aus der App +- Moderne, dunkle BenutzeroberflĂ€che (CustomTkinter) + +--- + +## Installation (Endnutzer) + +Lade die neueste Version von der [Releases-Seite](https://git.joelunger.de/public/fcco-Converter/releases) herunter: + +- **Windows**: `fcco-Converter-windows.zip` entpacken und `fcco-Converter.exe` starten. +- **macOS**: `fcco-Converter-macos.zip` entpacken und `fcco-Converter` starten. + +FFmpeg wird automatisch beim ersten Start heruntergeladen. Keine weitere Installation nötig. + +--- + +## Updates + +In der App auf **"Update prĂŒfen"** klicken. Wenn eine neue Version verfĂŒgbar ist, wird sie direkt heruntergeladen und installiert. Danach muss die App einmal neu gestartet werden. + +--- + +## Entwicklung + +### Voraussetzungen + +- Python 3.11 oder neuer +- Virtuelle Umgebung empfohlen + +### Setup + +```bash +python -m venv .venv +source .venv/bin/activate # macOS / Linux +# .venv\Scripts\activate # Windows + +pip install -r requirements.txt +``` + +### Starten + +```bash +python main.py +``` + +### Build (Standalone-Executable) + +```bash +python build.py +``` + +Die fertige Datei liegt anschließend im `dist/`-Verzeichnis. + +--- + +## Projektstruktur + +``` +fcco-Converter/ +├── main.py # Einstiegspunkt +├── build.py # Build-Skript (PyInstaller) +├── requirements.txt # AbhĂ€ngigkeiten +├── icon.ico # Programm-Icon +├── fcco_converter/ +│ ├── __init__.py +│ ├── config.py # Einstellungen & Konfiguration +│ ├── converter.py # FFmpeg-Konvertierung +│ ├── metadata.py # ID3-Tags & Cover +│ ├── updater.py # Auto-Update von Gitea Releases +│ └── gui.py # CustomTkinter GUI +└── README.md +``` + +--- + +## Hinweise + +- Nur `.wav`-Dateien werden als Eingabe akzeptiert. +- Cover-Bilder mĂŒssen `.jpg`, `.jpeg` oder `.png` sein. +- Export erfolgt im `.mp3`-Format (192 kbit/s). +- Einstellungen werden in `~/.config/fcco-Converter/` (Linux), `~/Library/Application Support/fcco-Converter/` (macOS) bzw. `%APPDATA%/fcco-Converter/` (Windows) gespeichert. \ No newline at end of file diff --git a/build.py b/build.py new file mode 100644 index 0000000..3063cf1 --- /dev/null +++ b/build.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +""" +fcco-Converter V2 – Build-Skript + +Erstellt plattformabhĂ€ngige Standalone-Executables mit PyInstaller. +Aufruf: python build.py +""" + +import platform +import subprocess +import sys +from pathlib import Path + + +def build(): + system = platform.system() + project_dir = Path(__file__).parent + + # Basis-Argumente fĂŒr PyInstaller + args = [ + sys.executable, + "-m", "PyInstaller", + "--onefile", + "--windowed", + "--name", "fcco-Converter", + "--clean", + ] + + # Icon hinzufĂŒgen (wenn vorhanden) + icon_path = project_dir / "icon.ico" + if icon_path.exists(): + if system == "Windows": + args.extend(["--icon", str(icon_path)]) + elif system == "Darwin": + # macOS braucht .icns – .ico funktioniert trotzdem als Fallback + args.extend(["--icon", str(icon_path)]) + + # Versteckte Imports (die PyInstaller evtl. nicht automatisch findet) + hidden_imports = [ + "customtkinter", + "mutagen", + "mutagen.easyid3", + "mutagen.id3", + "static_ffmpeg", + ] + for imp in hidden_imports: + args.extend(["--hidden-import", imp]) + + # CustomTkinter-Daten einbinden + try: + import customtkinter + ctk_path = Path(customtkinter.__file__).parent + if system == "Windows": + args.extend(["--add-data", f"{ctk_path};customtkinter"]) + else: + args.extend(["--add-data", f"{ctk_path}:customtkinter"]) + except ImportError: + print("WARNUNG: customtkinter nicht installiert. Bitte 'pip install -r requirements.txt' ausfĂŒhren.") + + # Hauptdatei + args.append(str(project_dir / "main.py")) + + print(f"Build fĂŒr {system}...") + print(f"Befehl: {' '.join(args)}") + print() + + result = subprocess.run(args, cwd=str(project_dir)) + if result.returncode == 0: + print() + print(f"Build erfolgreich! Ausgabe in: {project_dir / 'dist'}") + else: + print() + print(f"Build fehlgeschlagen (Code {result.returncode}).") + sys.exit(1) + + +if __name__ == "__main__": + build() diff --git a/fcco_converter/__init__.py b/fcco_converter/__init__.py new file mode 100644 index 0000000..c94a406 --- /dev/null +++ b/fcco_converter/__init__.py @@ -0,0 +1 @@ +# fcco-Converter V2 diff --git a/fcco_converter/config.py b/fcco_converter/config.py new file mode 100644 index 0000000..7cbfac8 --- /dev/null +++ b/fcco_converter/config.py @@ -0,0 +1,134 @@ +""" +fcco-Converter V2 – Konfigurationsmodul + +LĂ€dt und speichert Anwendungseinstellungen plattformĂŒbergreifend. +Verwendet pathlib fĂŒr alle Pfadoperationen. +""" + +import json +import platform +from pathlib import Path + + +# StandardmĂ€ĂŸige Export-Kategorien +DEFAULT_EXPORT_PATHS = { + "Gottesdienst": "gottesdienste", + "Hochzeit": "hochzeiten", + "Weissagung": "weissagungen", + "Jugend": "jugend", +} + +# Kurze deutsche Wochentagsnamen fĂŒr Ordnerbenennung +WEEKDAY_SHORT_DE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] + +# ---- Audio-Einstellungen: GĂŒltige Werte ---- + +BITRATE_OPTIONS = ["64", "96", "128", "160", "192", "224", "256", "320"] +SAMPLERATE_OPTIONS = ["Original", "22050", "44100", "48000"] +CHANNEL_OPTIONS = ["Original", "Mono", "Stereo"] +ENCODING_MODE_OPTIONS = ["CBR", "VBR"] +VBR_QUALITY_OPTIONS = [str(i) for i in range(10)] # 0 (beste) bis 9 (schlechteste) +OUTPUT_FORMAT_OPTIONS = ["mp3", "flac", "ogg", "wav"] + + +def _get_config_dir() -> Path: + """Gibt das plattformabhĂ€ngige Konfigurationsverzeichnis zurĂŒck.""" + system = platform.system() + if system == "Windows": + base = Path.home() / "AppData" / "Roaming" + elif system == "Darwin": + base = Path.home() / "Library" / "Application Support" + else: # Linux / andere + base = Path.home() / ".config" + config_dir = base / "fcco-Converter" + config_dir.mkdir(parents=True, exist_ok=True) + return config_dir + + +CONFIG_DIR = _get_config_dir() +SETTINGS_FILE = CONFIG_DIR / "settings.json" +HISTORY_FILE = CONFIG_DIR / "history.json" + + +# ---- Standard-Einstellungen ---- + +DEFAULT_SETTINGS = { + # Allgemein + "threads": 0, # 0 = auto (alle verfĂŒgbaren Kerne) + "appearance_mode": "Dark", # Dark / Light / System + "check_updates_on_start": True, + + # Audio & Konvertierung + "audio": { + "output_format": "mp3", + "bitrate": "192", # kbit/s (nur CBR) + "encoding_mode": "CBR", # CBR oder VBR + "vbr_quality": "2", # 0 (beste) bis 9 (schlechteste) + "sample_rate": "Original", # Original, 22050, 44100, 48000 + "channels": "Original", # Original, Mono, Stereo + "overwrite_existing": True, + }, + + # Metadaten + "metadata": { + "artist": "", + "cover": "", + "auto_track_number": True, + "auto_album_from_folder": True, + }, + + # Export-Pfade + "export_paths": DEFAULT_EXPORT_PATHS.copy(), +} + + +def _deep_merge(base: dict, override: dict) -> dict: + """Merged override-Werte in base, ohne fehlende SchlĂŒssel zu verlieren.""" + result = base.copy() + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = _deep_merge(result[key], value) + else: + result[key] = value + return result + + +def load_settings() -> dict: + """LĂ€dt die Einstellungen aus der JSON-Datei. + Fehlende SchlĂŒssel werden mit Standardwerten aufgefĂŒllt. + """ + settings = DEFAULT_SETTINGS.copy() + # Deep-copy verschachtelter Dicts + settings["audio"] = DEFAULT_SETTINGS["audio"].copy() + settings["metadata"] = DEFAULT_SETTINGS["metadata"].copy() + settings["export_paths"] = DEFAULT_SETTINGS["export_paths"].copy() + + if SETTINGS_FILE.exists(): + try: + with open(SETTINGS_FILE, "r", encoding="utf-8") as f: + loaded = json.load(f) + settings = _deep_merge(settings, loaded) + except (json.JSONDecodeError, OSError): + pass # Bei fehlerhafter Datei: Standardwerte nutzen + + return settings + + +def save_settings(settings: dict) -> None: + """Speichert die Einstellungen in die JSON-Datei.""" + with open(SETTINGS_FILE, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=2, ensure_ascii=False) + + +def save_export_history(entry: dict) -> None: + """FĂŒgt einen Export-Eintrag zur Historie hinzu.""" + history = [] + if HISTORY_FILE.exists(): + try: + with open(HISTORY_FILE, "r", encoding="utf-8") as f: + history = json.load(f) + except (json.JSONDecodeError, OSError): + history = [] + history.append(entry) + with open(HISTORY_FILE, "w", encoding="utf-8") as f: + json.dump(history, f, indent=2, ensure_ascii=False) diff --git a/fcco_converter/converter.py b/fcco_converter/converter.py new file mode 100644 index 0000000..2e1f8bd --- /dev/null +++ b/fcco_converter/converter.py @@ -0,0 +1,299 @@ +""" +fcco-Converter V2 – Konvertierungsmodul + +EnthĂ€lt die FFmpeg-basierte Audio-Konvertierung mit static-ffmpeg. +UnterstĂŒtzt erweiterte Einstellungen (Bitrate, Sample-Rate, KanĂ€le, VBR/CBR). +""" + +import os +import subprocess +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Callable + +import static_ffmpeg + +from .metadata import apply_id3_tags + + +def _ensure_ffmpeg() -> tuple[str, str]: + """Stellt sicher, dass ffmpeg und ffprobe verfĂŒgbar sind. + + Returns: + (ffmpeg_path, ffprobe_path) + """ + ffmpeg_path, ffprobe_path = static_ffmpeg.run.get_or_fetch_platform_executables_else_raise() + return ffmpeg_path, ffprobe_path + + +def get_duration(file_path: Path, ffprobe_path: str) -> float: + """Ermittelt die Dauer einer Audiodatei in Sekunden.""" + cmd = [ + ffprobe_path, + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + str(file_path), + ] + try: + result = subprocess.check_output( + cmd, universal_newlines=True, stderr=subprocess.DEVNULL + ).strip() + return float(result) + except (subprocess.CalledProcessError, ValueError): + return 0.0 + + +def _build_ffmpeg_cmd( + ffmpeg_path: str, + input_path: Path, + output_path: Path, + audio_settings: dict, + threads: int = 0, +) -> list[str]: + """Baut den ffmpeg-Befehl basierend auf den Audio-Einstellungen. + + Args: + ffmpeg_path: Pfad zur ffmpeg-Binary. + input_path: Quell-Audiodatei. + output_path: Ziel-Ausgabedatei. + audio_settings: Dict mit Audio-Konfiguration. + threads: Thread-Anzahl (0 = auto). + + Returns: + Liste der Befehlsargumente. + """ + output_format = audio_settings.get("output_format", "mp3") + encoding_mode = audio_settings.get("encoding_mode", "CBR") + bitrate = audio_settings.get("bitrate", "192") + vbr_quality = audio_settings.get("vbr_quality", "2") + sample_rate = audio_settings.get("sample_rate", "Original") + channels = audio_settings.get("channels", "Original") + overwrite = audio_settings.get("overwrite_existing", True) + + cmd = [ffmpeg_path, "-i", str(input_path)] + + # Threads + cmd.extend(["-threads", str(threads) if threads > 0 else "0"]) + + # Format-spezifische Codec-Einstellungen + if output_format == "mp3": + cmd.extend(["-c:a", "libmp3lame"]) + if encoding_mode == "VBR": + cmd.extend(["-q:a", vbr_quality]) + else: # CBR + cmd.extend(["-b:a", f"{bitrate}k"]) + + elif output_format == "flac": + cmd.extend(["-c:a", "flac"]) + + elif output_format == "ogg": + cmd.extend(["-c:a", "libvorbis"]) + if encoding_mode == "VBR": + # OGG VBR: QualitĂ€t 0–10 (wir mappen 0–9 auf 1–10) + q = min(10, max(0, 10 - int(vbr_quality))) + cmd.extend(["-q:a", str(q)]) + else: + cmd.extend(["-b:a", f"{bitrate}k"]) + + elif output_format == "wav": + cmd.extend(["-c:a", "pcm_s16le"]) + + # Sample-Rate + if sample_rate != "Original": + cmd.extend(["-ar", sample_rate]) + + # KanĂ€le + if channels == "Mono": + cmd.extend(["-ac", "1"]) + elif channels == "Stereo": + cmd.extend(["-ac", "2"]) + + # Überschreiben + if overwrite: + cmd.append("-y") + else: + cmd.append("-n") + + cmd.append(str(output_path)) + return cmd + + +def convert_single_file( + input_path: Path, + output_path: Path, + ffmpeg_path: str, + ffprobe_path: str, + audio_settings: dict | None = None, + threads: int = 0, + progress_callback: Callable[[int], None] | None = None, +) -> None: + """Konvertiert eine einzelne Audiodatei. + + Args: + input_path: Pfad zur Quelldatei. + output_path: Pfad fĂŒr die Zieldatei. + ffmpeg_path: Pfad zur ffmpeg-Binary. + ffprobe_path: Pfad zur ffprobe-Binary. + audio_settings: Dict mit Audio-Konfiguration (aus config). + threads: Anzahl der Threads (0 = auto). + progress_callback: Wird mit Prozent (0–100) aufgerufen. + + Raises: + RuntimeError: Bei Fehlern in der Konvertierung. + """ + if audio_settings is None: + audio_settings = {"output_format": "mp3", "bitrate": "192", "encoding_mode": "CBR"} + + output_path.parent.mkdir(parents=True, exist_ok=True) + + duration = get_duration(input_path, ffprobe_path) + if duration <= 0: + raise RuntimeError(f"Dauer der Datei konnte nicht ermittelt werden: {input_path.name}") + + cmd = _build_ffmpeg_cmd(ffmpeg_path, input_path, output_path, audio_settings, threads) + + process = subprocess.Popen( + cmd, + stderr=subprocess.PIPE, + stdout=subprocess.DEVNULL, + universal_newlines=True, + ) + + while True: + line = process.stderr.readline() + if not line: + break + if "time=" in line and progress_callback: + try: + time_str = line.split("time=")[1].split(" ")[0] + h, m, s = map(float, time_str.split(":")) + current_time = h * 3600 + m * 60 + s + percentage = min(int((current_time / duration) * 100), 100) + progress_callback(percentage) + except (ValueError, IndexError): + pass + + process.wait() + if process.returncode != 0: + raise RuntimeError(f"FFmpeg-Fehler (Code {process.returncode}) bei: {input_path.name}") + + +def convert_file_entry( + entry: dict, + index: int, + export_dir: Path, + meta: dict, + use_subdirs: bool, + ffmpeg_path: str, + ffprobe_path: str, + audio_settings: dict | None = None, + threads: int = 0, + progress_callback: Callable[[int, int, str], None] | None = None, +) -> tuple[int, str, str | None]: + """Konvertiert eine Datei und schreibt die Metadaten. + + Returns: + (index, filename, error_or_None) + """ + if audio_settings is None: + audio_settings = {"output_format": "mp3", "bitrate": "192", "encoding_mode": "CBR"} + + file_path = Path(entry["full_path"]) + title = file_path.stem + output_format = audio_settings.get("output_format", "mp3") + + try: + if use_subdirs and entry["rel_dir"]: + output_dir = export_dir / entry["rel_dir"] + else: + output_dir = export_dir + + output_file = output_dir / f"{title}.{output_format}" + + def _on_progress(pct: int): + if progress_callback: + progress_callback(index, pct, title) + + convert_single_file( + input_path=file_path, + output_path=output_file, + ffmpeg_path=ffmpeg_path, + ffprobe_path=ffprobe_path, + audio_settings=audio_settings, + threads=threads, + progress_callback=_on_progress, + ) + + # ID3-Metadaten nur fĂŒr MP3 schreiben + if output_format == "mp3": + cover_path = Path(meta["cover"]) if meta.get("cover") else None + apply_id3_tags( + mp3_path=output_file, + title=title, + artist=meta.get("artist", ""), + album=meta.get("album", ""), + track_number=index + 1, + year=meta.get("year", ""), + cover_path=cover_path, + ) + + return (index, f"{title}.{output_format}", None) + + except Exception as e: + return (index, file_path.name, str(e)) + + +def run_batch_conversion( + entries: list[dict], + export_dir: Path, + meta: dict, + use_subdirs: bool, + audio_settings: dict | None = None, + max_workers: int = 0, + progress_callback: Callable[[int, int, str], None] | None = None, + completion_callback: Callable[[list[str], list[tuple[str, str]]], None] | None = None, +) -> None: + """FĂŒhrt die Batch-Konvertierung aller Dateien durch.""" + if audio_settings is None: + audio_settings = {"output_format": "mp3", "bitrate": "192", "encoding_mode": "CBR"} + + ffmpeg_path, ffprobe_path = _ensure_ffmpeg() + + if max_workers <= 0: + max_workers = os.cpu_count() or 1 + + successful_files = [] + errors = [] + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit( + convert_file_entry, + entry=entry, + index=idx, + export_dir=export_dir, + meta=meta, + use_subdirs=use_subdirs, + ffmpeg_path=ffmpeg_path, + ffprobe_path=ffprobe_path, + audio_settings=audio_settings, + threads=0, + progress_callback=progress_callback, + ): idx + for idx, entry in enumerate(entries) + } + + for future in as_completed(futures): + index, filename, error = future.result() + if error: + errors.append((filename, error)) + if progress_callback: + progress_callback(index, 100, filename) + else: + successful_files.append(filename) + if progress_callback: + progress_callback(index, 100, filename) + + if completion_callback: + completion_callback(successful_files, errors) diff --git a/fcco_converter/gui.py b/fcco_converter/gui.py new file mode 100644 index 0000000..7a01dbe --- /dev/null +++ b/fcco_converter/gui.py @@ -0,0 +1,949 @@ +""" +fcco-Converter V2 – GUI-Modul + +Moderne GUI mit CustomTkinter, optimiert fĂŒr macOS und Windows. + +Bekannte macOS-Probleme und deren Lösungen: +- Buttons brauchen mehrere Klicks: Gelöst durch globales Fokus-Binding + und Vermeidung von grid_propagate(False) / fixen Höhen auf Containern. +- grab_set() auf Dialogen: Komplett entfernt auf macOS. +""" + +import os +import platform +import threading +from datetime import datetime +from pathlib import Path +from tkinter import filedialog, messagebox + +import customtkinter as ctk + +from .config import ( + DEFAULT_EXPORT_PATHS, + BITRATE_OPTIONS, + SAMPLERATE_OPTIONS, + CHANNEL_OPTIONS, + ENCODING_MODE_OPTIONS, + VBR_QUALITY_OPTIONS, + OUTPUT_FORMAT_OPTIONS, + load_settings, + save_settings, + save_export_history, +) +from .converter import run_batch_conversion, _ensure_ffmpeg +from .metadata import ( + generate_album_name, + generate_folder_name, + generate_date_part, + extract_event_from_folder, +) +from .updater import ( + get_current_version, + check_for_updates, + download_and_install_update, +) +from .icons import IconSet + +IS_MACOS = platform.system() == "Darwin" + +# ========================================================================= +# Farbschema +# ========================================================================= + +T = { + "bg": "#000000", + "surface": "#111111", + "elevated": "#1a1a1a", + "card": "#222222", + "input": "#000000", + "accent": "#ffffff", + "accent_h": "#e6e6e6", + "success": "#ffffff", + "success_h": "#e6e6e6", + "warn": "#ffffff", + "error": "#ffffff", + "text": "#ffffff", + "text2": "#b3b3b3", + "muted": "#808080", + "border": "#333333", + "btn2": "#1a1a1a", + "btn2_h": "#333333", +} + + +# ========================================================================= +# App +# ========================================================================= + +class App(ctk.CTk): + + def __init__(self): + super().__init__() + ctk.set_appearance_mode("dark") + ctk.set_default_color_theme("blue") + + self.title(f"fcco Converter v{get_current_version()}") + self.geometry("900x700") + self.minsize(700, 500) + self.configure(fg_color=T["bg"]) + + # Icon (nur Windows) + icon = Path(__file__).parent.parent / "icon.ico" + if icon.exists() and not IS_MACOS: + try: + self.iconbitmap(str(icon)) + except Exception: + pass + + self.icons = IconSet(size=(18, 18)) + + # State + self.settings = load_settings() + self.file_entries: list[dict] = [] + self.base_folder: Path | None = None + self.is_converting = False + self._ffmpeg_ready = False + + # FFmpeg im Hintergrund laden + threading.Thread(target=self._preload_ffmpeg, daemon=True).start() + + # Layout + self._build() + + # ---- macOS Fokus-Fix ---- + # Jeder Klick irgendwo im Fenster erzwingt den Fokus auf das + # angeklickte Widget. Das behebt das Problem, dass Buttons + # auf macOS mehrfach geklickt werden mĂŒssen. + self.bind_all("", self._on_click, add="+") + self.after(100, self._activate) + + self.protocol("WM_DELETE_WINDOW", self._on_close) + + # ----- Fokus-Management (behebt macOS-Bug) ----- + + def _on_click(self, event): + """Erzwingt Fokus auf das geklickte Widget.""" + try: + w = event.widget + if w and w.winfo_exists(): + w.focus_set() + except Exception: + pass + + def _activate(self): + """Bringt das Fenster nach vorne und aktiviert es.""" + self.lift() + self.attributes("-topmost", True) + self.after(50, lambda: self.attributes("-topmost", False)) + self.focus_force() + + def _btn(self, parent, text, command, **kwargs): + btn = ctk.CTkButton(parent, text=text, **kwargs) + if IS_MACOS and command: + # macOS Bugfix: Direkter Bind statt Canvas-Command + btn.bind("", lambda e: self.after(10, command)) + elif command: + btn.configure(command=command) + return btn + + def _preload_ffmpeg(self): + try: + _ensure_ffmpeg() + self._ffmpeg_ready = True + self.after(0, lambda: self._set_status("Bereit", T["success"])) + except Exception as e: + self.after(0, lambda: self._set_status(f"FFmpeg-Fehler: {e}", T["error"])) + + def _set_status(self, text, color=None): + self.status_lbl.configure(text=text, text_color=color or T["muted"]) + + # ================================================================= + # Layout + # ================================================================= + + def _build(self): + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(2, weight=1) + + self._make_header() + self._make_toolbar() + self._make_filelist() + self._make_progress() + self._make_footer() + + def _make_header(self): + h = ctk.CTkFrame(self, fg_color=T["surface"], corner_radius=0) + h.grid(row=0, column=0, sticky="ew") + h.grid_columnconfigure(1, weight=1) + + ctk.CTkLabel(h, text="fcco Converter", + font=ctk.CTkFont(size=20, weight="bold"), + text_color=T["text"]).grid(row=0, column=0, padx=(20, 0), pady=12, sticky="w") + + ctk.CTkLabel(h, text=f"v{get_current_version()}", + font=ctk.CTkFont(size=12), text_color=T["muted"] + ).grid(row=0, column=1, padx=(8, 0), pady=12, sticky="w") + + self.update_btn = self._btn( + h, text=" Update prĂŒfen", command=self._check_updates, + image=self.icons.refresh, width=150, height=32, + fg_color=T["btn2"], hover_color=T["btn2_h"], + border_width=1, border_color=T["border"], + text_color=T["text2"], font=ctk.CTkFont(size=12)) + self.update_btn.grid(row=0, column=2, padx=(0, 8), pady=12) + + self._btn( + h, text="", command=self._open_settings, + image=self.icons.settings, width=34, height=32, + fg_color=T["btn2"], hover_color=T["btn2_h"], + border_width=1, border_color=T["border"] + ).grid(row=0, column=3, padx=(0, 20), pady=12) + + def _make_toolbar(self): + tb = ctk.CTkFrame(self, fg_color="transparent") + tb.grid(row=1, column=0, sticky="ew", padx=20, pady=(10, 4)) + tb.grid_columnconfigure(2, weight=1) + + self._btn( + tb, text=" Dateien", command=self._pick_files, + image=self.icons.file_add, width=130, height=34, + fg_color=T["btn2"], hover_color=T["btn2_h"], + border_width=1, border_color=T["border"], + text_color=T["text"], font=ctk.CTkFont(size=13) + ).grid(row=0, column=0, padx=(0, 6)) + + self._btn( + tb, text=" Ordner", command=self._pick_folder, + image=self.icons.folder, width=130, height=34, + fg_color=T["btn2"], hover_color=T["btn2_h"], + border_width=1, border_color=T["border"], + text_color=T["text"], font=ctk.CTkFont(size=13) + ).grid(row=0, column=1, padx=(0, 6)) + + self.count_lbl = ctk.CTkLabel(tb, text="Keine Dateien", + text_color=T["muted"], font=ctk.CTkFont(size=12)) + self.count_lbl.grid(row=0, column=2, sticky="w", padx=(10, 0)) + + self.conv_btn = self._btn( + tb, text=" Konvertieren", command=self._convert, + image=self.icons.play, width=160, height=36, + fg_color=T["accent"], hover_color=T["accent_h"], + text_color="#000000", font=ctk.CTkFont(size=14, weight="bold")) + self.conv_btn.grid(row=0, column=3, sticky="e") + + def _make_filelist(self): + box = ctk.CTkFrame(self, fg_color=T["surface"], + border_width=1, border_color=T["border"], corner_radius=8) + box.grid(row=2, column=0, sticky="nsew", padx=20, pady=6) + box.grid_columnconfigure(0, weight=1) + box.grid_rowconfigure(1, weight=1) + + hdr = ctk.CTkFrame(box, fg_color="transparent") + hdr.grid(row=0, column=0, sticky="ew", padx=12, pady=(8, 0)) + hdr.grid_columnconfigure(1, weight=1) + + ctk.CTkLabel(hdr, text="Dateien", + font=ctk.CTkFont(size=13, weight="bold"), + text_color=T["text"]).grid(row=0, column=0, sticky="w") + + self.info_lbl = ctk.CTkLabel(hdr, text="", font=ctk.CTkFont(size=11), + text_color=T["muted"]) + self.info_lbl.grid(row=0, column=1, sticky="e") + + self.scroll = ctk.CTkScrollableFrame(box, fg_color="transparent", + scrollbar_button_color=T["elevated"], + scrollbar_button_hover_color=T["border"]) + self.scroll.grid(row=1, column=0, sticky="nsew", padx=6, pady=(4, 8)) + self.scroll.grid_columnconfigure(1, weight=1) + + self.rows: list[dict] = [] + + def _make_progress(self): + pf = ctk.CTkFrame(self, fg_color="transparent") + pf.grid(row=3, column=0, sticky="ew", padx=20, pady=(2, 0)) + pf.grid_columnconfigure(0, weight=1) + + self.total_bar = ctk.CTkProgressBar(pf, height=6, + progress_color=T["accent"], fg_color=T["elevated"], corner_radius=3) + self.total_bar.grid(row=0, column=0, sticky="ew") + self.total_bar.set(0) + + self.prog_lbl = ctk.CTkLabel(pf, text="", font=ctk.CTkFont(size=11), + text_color=T["muted"]) + self.prog_lbl.grid(row=1, column=0, sticky="w", pady=(2, 0)) + + def _make_footer(self): + f = ctk.CTkFrame(self, fg_color=T["surface"], corner_radius=0) + f.grid(row=4, column=0, sticky="ew") + + self.status_lbl = ctk.CTkLabel(f, text="FFmpeg wird geladen...", + font=ctk.CTkFont(size=11), text_color=T["muted"]) + self.status_lbl.pack(side="left", padx=20, pady=6) + + audio = self.settings.get("audio", {}) + self.audio_lbl = ctk.CTkLabel(f, text=self._audio_summary(audio), + font=ctk.CTkFont(size=11), text_color=T["muted"]) + self.audio_lbl.pack(side="right", padx=20, pady=6) + + def _audio_summary(self, audio: dict) -> str: + fmt = audio.get("output_format", "mp3").upper() + mode = audio.get("encoding_mode", "CBR") + if mode == "CBR": + s = f"{fmt} CBR {audio.get('bitrate', '192')}k" + else: + s = f"{fmt} VBR Q{audio.get('vbr_quality', '2')}" + sr = audio.get("sample_rate", "Original") + if sr != "Original": + s += f" {sr}Hz" + ch = audio.get("channels", "Original") + if ch != "Original": + s += f" {ch}" + return s + + # ================================================================= + # Dateien + # ================================================================= + + def _pick_files(self): + p = filedialog.askopenfilenames(filetypes=[("WAV", "*.wav"), ("Alle", "*.*")]) + if p: + self.base_folder = None + self.file_entries = [{"full_path": str(x), "rel_dir": ""} for x in p] + self._refresh_list() + self.after(30, self._activate) + + def _pick_folder(self): + d = filedialog.askdirectory() + if d: + dp = Path(d) + self.base_folder = dp + self.file_entries = [] + for r, _, fs in os.walk(dp): + for fn in fs: + if fn.lower().endswith(".wav"): + fp = Path(r) / fn + rel = str(Path(r).relative_to(dp)) + self.file_entries.append({"full_path": str(fp), "rel_dir": "" if rel == "." else rel}) + self._refresh_list() + self.after(30, self._activate) + + def _refresh_list(self): + for w in self.rows: + w["fr"].destroy() + self.rows.clear() + + self.file_entries.sort(key=lambda x: Path(x["full_path"]).name.lower()) + + for i, e in enumerate(self.file_entries): + name = Path(e["full_path"]).name + disp = f"{e['rel_dir']}/{name}" if e["rel_dir"] else name + + fr = ctk.CTkFrame(self.scroll, fg_color="transparent") + fr.grid(row=i, column=0, sticky="ew", pady=1, columnspan=4) + fr.grid_columnconfigure(1, weight=1) + + ctk.CTkLabel(fr, text=f"{i+1}", font=ctk.CTkFont(size=11), + text_color=T["muted"], width=30).grid(row=0, column=0, padx=(4, 6)) + + ctk.CTkLabel(fr, text=disp, font=ctk.CTkFont(size=12), anchor="w", + text_color=T["text"]).grid(row=0, column=1, sticky="w") + + sl = ctk.CTkLabel(fr, text="Wartet", font=ctk.CTkFont(size=11), + text_color=T["muted"], width=70) + sl.grid(row=0, column=2, padx=(8, 4)) + + pb = ctk.CTkProgressBar(fr, width=90, height=4, + progress_color=T["accent"], fg_color=T["elevated"], corner_radius=2) + pb.grid(row=0, column=3, padx=(0, 8)) + pb.set(0) + + self.rows.append({"fr": fr, "sl": sl, "pb": pb}) + + n = len(self.file_entries) + self.count_lbl.configure(text=f"{n} Datei{'en' if n != 1 else ''}") + self.info_lbl.configure(text=f"{n} EintrĂ€ge") + + # ================================================================= + # Konvertierung + # ================================================================= + + def _convert(self): + if not self.file_entries: + messagebox.showwarning("Keine Dateien", "Bitte zuerst Dateien auswĂ€hlen.") + self.after(30, self._activate) + return + if self.is_converting: + return + if not self._ffmpeg_ready: + messagebox.showwarning("FFmpeg", "FFmpeg wird noch geladen.") + self.after(30, self._activate) + return + + meta = self._meta_dialog() + if not meta: + return + + ctype = meta.get("type", "Sonstiges") + event = meta.get("event", "").strip() + dpart = meta.get("date_part", generate_date_part()) + + if ctype in self.settings.get("export_paths", {}) and ctype != "Sonstiges": + bd = Path(self.settings["export_paths"][ctype]) + bd.mkdir(parents=True, exist_ok=True) + ed = bd / generate_folder_name(dpart, event) + ed.mkdir(parents=True, exist_ok=True) + else: + chosen = filedialog.askdirectory(title="Export-Ordner wĂ€hlen") + if not chosen: + return + ed = Path(chosen) + + self.settings["last_export_dir"] = str(ed) + save_settings(self.settings) + + subdirs = self.base_folder is not None + self.is_converting = True + self.conv_btn.configure(state="disabled", text=" Konvertiert...") + self.total_bar.set(0) + self.prog_lbl.configure(text="Konvertierung gestartet...") + self._set_status("Konvertierung...", T["warn"]) + + workers = self.settings.get("threads", 0) or os.cpu_count() or 1 + audio = self.settings.get("audio", {}) + total = len(self.file_entries) + done = {"n": 0} + + def on_prog(idx, pct, fn): + self.after(0, lambda: self._file_prog(idx, pct)) + if pct >= 100: + done["n"] += 1 + r = done["n"] / total + self.after(0, lambda r=r, c=done["n"]: ( + self.total_bar.set(r), + self.prog_lbl.configure(text=f"{c} / {total}") + )) + + def on_done(ok, errs): + self.after(0, lambda: self._conv_done(ok, errs, ed)) + + threading.Thread(target=run_batch_conversion, daemon=True, kwargs={ + "entries": self.file_entries, "export_dir": ed, "meta": meta, + "use_subdirs": subdirs, "audio_settings": audio, + "max_workers": workers, "progress_callback": on_prog, + "completion_callback": on_done, + }).start() + + def _file_prog(self, idx, pct): + if 0 <= idx < len(self.rows): + r = self.rows[idx] + r["pb"].set(pct / 100) + if pct >= 100: + r["sl"].configure(text="Fertig", text_color=T["success"]) + else: + r["sl"].configure(text=f"{pct}%", text_color=T["accent"]) + + def _conv_done(self, ok, errs, ed): + self.is_converting = False + self.conv_btn.configure(state="normal", text=" Konvertieren") + self.total_bar.set(1.0) + self._set_status("Bereit", T["success"]) + + for fn, _ in errs: + for i, e in enumerate(self.file_entries): + if Path(e["full_path"]).name == fn and i < len(self.rows): + self.rows[i]["sl"].configure(text="Fehler", text_color=T["error"]) + break + + save_export_history({ + "datum": datetime.now().strftime("%d.%m.%Y %H:%M"), + "ordner": str(ed), "dateien": ok, + }) + + msg = f"{len(ok)} Dateien konvertiert." + if errs: + msg += f"\n{len(errs)} Fehler:" + for fn, err in errs[:5]: + msg += f"\n {fn}: {err}" + + res = messagebox.askquestion("Fertig", f"{msg}\n\nProgramm schließen?") + if res == "yes": + self.destroy() + else: + self.after(30, self._activate) + + # ================================================================= + # Metadaten + Export Dialog (KEIN Scrolling) + # ================================================================= + + def _meta_dialog(self) -> dict | None: + meta = self.settings.get("metadata", {}).copy() + audio = self.settings.get("audio", {}) + + if self.file_entries: + fp = Path(self.file_entries[0]["full_path"]) + dpart, veranst = extract_event_from_folder(fp) + if not dpart: + dpart = generate_date_part() + else: + dpart = generate_date_part() + veranst = "" + + meta["album"] = generate_album_name(veranst) + meta["year"] = str(datetime.now().year) + + d = ctk.CTkToplevel(self) + d.title("Metadaten und Export") + d.geometry("620x350") + d.transient(self) + d.resizable(False, False) + d.configure(fg_color=T["bg"]) + + result = {} + + # ---- Kompaktes Layout ohne Scroll ---- + main = ctk.CTkFrame(d, fg_color="transparent") + main.pack(fill="both", expand=True, padx=20, pady=(16, 8)) + + # --- Metadaten (links oben) --- + left = ctk.CTkFrame(main, fg_color="transparent") + left.pack(fill="x") + + row0 = ctk.CTkFrame(left, fg_color="transparent") + row0.pack(fill="x", pady=(0, 6)) + row0.grid_columnconfigure(0, weight=1) + row0.grid_columnconfigure(1, weight=1) + + ctk.CTkLabel(row0, text="KĂŒnstler", text_color=T["text2"], + font=ctk.CTkFont(size=12)).grid(row=0, column=0, sticky="w") + artist_e = ctk.CTkEntry(row0, fg_color=T["input"], border_color=T["border"], + text_color=T["text"]) + artist_e.grid(row=1, column=0, sticky="ew", padx=(0, 8)) + artist_e.insert(0, meta.get("artist", "")) + + ctk.CTkLabel(row0, text="Album", text_color=T["text2"], + font=ctk.CTkFont(size=12)).grid(row=0, column=1, sticky="w") + album_e = ctk.CTkEntry(row0, fg_color=T["input"], border_color=T["border"], + text_color=T["text"]) + album_e.grid(row=1, column=1, sticky="ew") + album_e.insert(0, meta.get("album", "")) + + row1 = ctk.CTkFrame(left, fg_color="transparent") + row1.pack(fill="x", pady=(0, 6)) + row1.grid_columnconfigure(1, weight=1) + + ctk.CTkLabel(row1, text="Jahr", text_color=T["text2"], + font=ctk.CTkFont(size=12)).grid(row=0, column=0, sticky="w") + year_e = ctk.CTkEntry(row1, width=80, fg_color=T["input"], border_color=T["border"], + text_color=T["text"]) + year_e.grid(row=1, column=0, sticky="w", padx=(0, 8)) + year_e.insert(0, meta.get("year", "")) + + ctk.CTkLabel(row1, text="Cover-Bild", text_color=T["text2"], + font=ctk.CTkFont(size=12)).grid(row=0, column=1, sticky="w") + cover_fr = ctk.CTkFrame(row1, fg_color="transparent") + cover_fr.grid(row=1, column=1, sticky="ew") + cover_fr.grid_columnconfigure(0, weight=1) + cover_e = ctk.CTkEntry(cover_fr, fg_color=T["input"], border_color=T["border"], + text_color=T["text"]) + cover_e.grid(row=0, column=0, sticky="ew") + cover_e.insert(0, meta.get("cover", "")) + + def _bc(): + p = filedialog.askopenfilename(filetypes=[("Bilder", "*.jpg *.jpeg *.png")]) + if p: + cover_e.delete(0, "end") + cover_e.insert(0, p) + d.after(30, lambda: (d.lift(), d.focus_force())) + + self._btn(cover_fr, text="", command=_bc, image=self.icons.browse, + width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"], + border_width=1, border_color=T["border"] + ).grid(row=0, column=1, padx=(4, 0)) + + # --- Trennlinie --- + ctk.CTkFrame(main, height=1, fg_color=T["border"]).pack(fill="x", pady=10) + + # --- Mitte: Veranstaltung + Datum nebeneinander --- + mid = ctk.CTkFrame(main, fg_color="transparent") + mid.pack(fill="x") + mid.grid_columnconfigure(0, weight=0) + mid.grid_columnconfigure(1, weight=0) + mid.grid_columnconfigure(2, weight=1) + + # Veranstaltungstyp + ctk.CTkLabel(mid, text="Typ", text_color=T["text2"], + font=ctk.CTkFont(size=12, weight="bold")).grid(row=0, column=0, sticky="w", pady=(0, 4)) + ctype_var = ctk.StringVar(value="Gottesdienst") + for i, opt in enumerate(["Gottesdienst", "Hochzeit", "Weissagung", "Jugend", "Sonstiges"]): + ctk.CTkRadioButton(mid, text=opt, variable=ctype_var, value=opt, + fg_color=T["accent"], hover_color=T["accent_h"], + border_color=T["border"], text_color=T["text"], + font=ctk.CTkFont(size=12) + ).grid(row=i+1, column=0, sticky="w", pady=1, padx=(0, 24)) + + # Datum + Veranstaltungsname + ctk.CTkLabel(mid, text="Datum", text_color=T["text2"], + font=ctk.CTkFont(size=12)).grid(row=0, column=1, sticky="w") + date_e = ctk.CTkEntry(mid, width=120, fg_color=T["input"], border_color=T["border"], + text_color=T["text"]) + date_e.grid(row=1, column=1, sticky="w", padx=(0, 12)) + date_e.insert(0, dpart) + + ctk.CTkLabel(mid, text="Veranstaltung (opt.)", text_color=T["text2"], + font=ctk.CTkFont(size=12)).grid(row=0, column=2, sticky="w") + event_e = ctk.CTkEntry(mid, fg_color=T["input"], border_color=T["border"], + text_color=T["text"]) + event_e.grid(row=1, column=2, sticky="ew") + event_e.insert(0, veranst) + + def _upd_album(*_): + album_e.delete(0, "end") + album_e.insert(0, generate_album_name(event_e.get().strip())) + event_e.bind("", _upd_album) + + # --- Buttons --- + bf = ctk.CTkFrame(d, fg_color="transparent") + bf.pack(fill="x", padx=20, pady=(8, 16)) + + self._btn(bf, text="Abbrechen", command=d.destroy, width=110, height=34, + fg_color=T["btn2"], hover_color=T["btn2_h"], + border_width=1, border_color=T["border"], + text_color=T["text2"] + ).pack(side="left") + + def _ok(): + result["artist"] = artist_e.get() + result["album"] = album_e.get() + result["year"] = year_e.get() + result["cover"] = cover_e.get() + result["type"] = ctype_var.get() + result["event"] = event_e.get().strip() + result["date_part"] = date_e.get().strip() + d.destroy() + + self._btn(bf, text=" Konvertieren starten", command=_ok, + image=self.icons.play, width=180, height=34, + fg_color=T["accent"], hover_color=T["accent_h"], + text_color="#000000", font=ctk.CTkFont(weight="bold") + ).pack(side="right") + + # Fokus (KEIN grab_set auf macOS!) + d.after(50, lambda: (d.lift(), d.focus_force())) + if not IS_MACOS: + d.after(200, lambda: d.grab_set()) + + self.wait_window(d) + self.after(30, self._activate) + return result if result else None + + # ================================================================= + # Einstellungen + # ================================================================= + + def _open_settings(self): + w = ctk.CTkToplevel(self) + w.title("Einstellungen") + w.geometry("560x580") + w.transient(self) + w.resizable(False, False) + w.configure(fg_color=T["bg"]) + + tabs = ctk.CTkTabview(w, fg_color=T["surface"], + segmented_button_fg_color=T["elevated"], + segmented_button_selected_color=T["accent"], + segmented_button_selected_hover_color=T["accent_h"], + segmented_button_unselected_color=T["elevated"], + segmented_button_unselected_hover_color=T["btn2_h"]) + tabs.pack(fill="both", expand=True, padx=12, pady=(12, 4)) + + t1 = tabs.add("Allgemein") + t2 = tabs.add("Audio") + t3 = tabs.add("Metadaten") + t4 = tabs.add("Export-Pfade") + + wg = {} # Widget-Sammlung + + # == Allgemein == + s1 = ctk.CTkScrollableFrame(t1, fg_color="transparent") + s1.pack(fill="both", expand=True, padx=4, pady=4) + + self._slabel(s1, "Leistung") + ctk.CTkLabel(s1, text="Threads (0 = automatisch)", text_color=T["text2"], + font=ctk.CTkFont(size=12)).pack(anchor="w") + te = ctk.CTkEntry(s1, width=100, fg_color=T["input"], border_color=T["border"], + text_color=T["text"]) + te.pack(anchor="w", pady=(2, 10)) + te.insert(0, str(self.settings.get("threads", 0))) + wg["threads"] = te + + self._slabel(s1, "Darstellung") + ctk.CTkLabel(s1, text="Theme", text_color=T["text2"], + font=ctk.CTkFont(size=12)).pack(anchor="w") + av = ctk.StringVar(value=self.settings.get("appearance_mode", "Dark")) + ctk.CTkOptionMenu(s1, values=["Dark", "Light", "System"], variable=av, + fg_color=T["input"], button_color=T["accent"], + button_hover_color=T["accent_h"], text_color=T["text"], + command=lambda v: ctk.set_appearance_mode(v) + ).pack(anchor="w", pady=(2, 10)) + wg["appearance"] = av + + self._slabel(s1, "Updates") + uv = ctk.BooleanVar(value=self.settings.get("check_updates_on_start", True)) + ctk.CTkCheckBox(s1, text="Beim Start auf Updates prĂŒfen", variable=uv, + fg_color=T["accent"], hover_color=T["accent_h"], + border_color=T["border"], text_color=T["text"], + font=ctk.CTkFont(size=12)).pack(anchor="w", pady=4) + wg["auto_update"] = uv + + # == Audio == + s2 = ctk.CTkScrollableFrame(t2, fg_color="transparent") + s2.pack(fill="both", expand=True, padx=4, pady=4) + + self._slabel(s2, "Ausgabeformat") + fv = ctk.StringVar(value=self.settings.get("audio", {}).get("output_format", "mp3")) + ctk.CTkOptionMenu(s2, values=OUTPUT_FORMAT_OPTIONS, variable=fv, + fg_color=T["input"], button_color=T["accent"], + button_hover_color=T["accent_h"], text_color=T["text"] + ).pack(anchor="w", pady=(2, 10)) + wg["fmt"] = fv + + self._slabel(s2, "Encoding") + mv = ctk.StringVar(value=self.settings.get("audio", {}).get("encoding_mode", "CBR")) + ctk.CTkOptionMenu(s2, values=ENCODING_MODE_OPTIONS, variable=mv, + fg_color=T["input"], button_color=T["accent"], + button_hover_color=T["accent_h"], text_color=T["text"] + ).pack(anchor="w", pady=(2, 6)) + wg["mode"] = mv + + ctk.CTkLabel(s2, text="Bitrate (kbit/s, nur CBR)", text_color=T["text2"], + font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(4, 0)) + bv = ctk.StringVar(value=self.settings.get("audio", {}).get("bitrate", "192")) + ctk.CTkOptionMenu(s2, values=BITRATE_OPTIONS, variable=bv, + fg_color=T["input"], button_color=T["accent"], + button_hover_color=T["accent_h"], text_color=T["text"] + ).pack(anchor="w", pady=(2, 6)) + wg["br"] = bv + + ctk.CTkLabel(s2, text="VBR-QualitĂ€t (0=best, 9=klein, nur VBR)", text_color=T["text2"], + font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(4, 0)) + vv = ctk.StringVar(value=self.settings.get("audio", {}).get("vbr_quality", "2")) + ctk.CTkOptionMenu(s2, values=VBR_QUALITY_OPTIONS, variable=vv, + fg_color=T["input"], button_color=T["accent"], + button_hover_color=T["accent_h"], text_color=T["text"] + ).pack(anchor="w", pady=(2, 10)) + wg["vbr"] = vv + + self._slabel(s2, "Sampling") + ctk.CTkLabel(s2, text="Sample-Rate (Hz)", text_color=T["text2"], + font=ctk.CTkFont(size=12)).pack(anchor="w") + sv = ctk.StringVar(value=self.settings.get("audio", {}).get("sample_rate", "Original")) + ctk.CTkOptionMenu(s2, values=SAMPLERATE_OPTIONS, variable=sv, + fg_color=T["input"], button_color=T["accent"], + button_hover_color=T["accent_h"], text_color=T["text"] + ).pack(anchor="w", pady=(2, 6)) + wg["sr"] = sv + + ctk.CTkLabel(s2, text="KanĂ€le", text_color=T["text2"], + font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(4, 0)) + cv = ctk.StringVar(value=self.settings.get("audio", {}).get("channels", "Original")) + ctk.CTkOptionMenu(s2, values=CHANNEL_OPTIONS, variable=cv, + fg_color=T["input"], button_color=T["accent"], + button_hover_color=T["accent_h"], text_color=T["text"] + ).pack(anchor="w", pady=(2, 10)) + wg["ch"] = cv + + self._slabel(s2, "Optionen") + ov = ctk.BooleanVar(value=self.settings.get("audio", {}).get("overwrite_existing", True)) + ctk.CTkCheckBox(s2, text="Bestehende Dateien ĂŒberschreiben", variable=ov, + fg_color=T["accent"], hover_color=T["accent_h"], + border_color=T["border"], text_color=T["text"], + font=ctk.CTkFont(size=12)).pack(anchor="w", pady=3) + wg["ow"] = ov + + # == Metadaten == + s3 = ctk.CTkScrollableFrame(t3, fg_color="transparent") + s3.pack(fill="both", expand=True, padx=4, pady=4) + + self._slabel(s3, "Standard-Metadaten") + ctk.CTkLabel(s3, text="KĂŒnstler", text_color=T["text2"], + font=ctk.CTkFont(size=12)).pack(anchor="w") + mae = ctk.CTkEntry(s3, width=380, fg_color=T["input"], border_color=T["border"], + text_color=T["text"]) + mae.pack(anchor="w", pady=(2, 8)) + mae.insert(0, self.settings.get("metadata", {}).get("artist", "")) + wg["m_artist"] = mae + + ctk.CTkLabel(s3, text="Standard-Cover", text_color=T["text2"], + font=ctk.CTkFont(size=12)).pack(anchor="w") + mcf = ctk.CTkFrame(s3, fg_color="transparent") + mcf.pack(anchor="w", fill="x", pady=(2, 10)) + mce = ctk.CTkEntry(mcf, width=340, fg_color=T["input"], border_color=T["border"], + text_color=T["text"]) + mce.pack(side="left") + mce.insert(0, self.settings.get("metadata", {}).get("cover", "")) + wg["m_cover"] = mce + + def _bc2(): + p = filedialog.askopenfilename(filetypes=[("Bilder", "*.jpg *.jpeg *.png")]) + if p: + mce.delete(0, "end") + mce.insert(0, p) + self._btn(mcf, text="", command=_bc2, image=self.icons.browse, + width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"], + border_width=1, border_color=T["border"] + ).pack(side="left", padx=(4, 0)) + + self._slabel(s3, "Automatisierung") + atv = ctk.BooleanVar(value=self.settings.get("metadata", {}).get("auto_track_number", True)) + ctk.CTkCheckBox(s3, text="Tracknummer automatisch vergeben", variable=atv, + fg_color=T["accent"], hover_color=T["accent_h"], + border_color=T["border"], text_color=T["text"], + font=ctk.CTkFont(size=12)).pack(anchor="w", pady=3) + wg["auto_track"] = atv + + aav = ctk.BooleanVar(value=self.settings.get("metadata", {}).get("auto_album_from_folder", True)) + ctk.CTkCheckBox(s3, text="Albumname aus Ordnername ableiten", variable=aav, + fg_color=T["accent"], hover_color=T["accent_h"], + border_color=T["border"], text_color=T["text"], + font=ctk.CTkFont(size=12)).pack(anchor="w", pady=3) + wg["auto_album"] = aav + + # == Export-Pfade == + s4 = ctk.CTkScrollableFrame(t4, fg_color="transparent") + s4.pack(fill="both", expand=True, padx=4, pady=4) + + self._slabel(s4, "Standard-Exportverzeichnisse") + pe = {} + for key in DEFAULT_EXPORT_PATHS: + ctk.CTkLabel(s4, text=key, text_color=T["text2"], + font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(6, 0)) + rf = ctk.CTkFrame(s4, fg_color="transparent") + rf.pack(anchor="w", fill="x", pady=(2, 2)) + ent = ctk.CTkEntry(rf, width=360, fg_color=T["input"], border_color=T["border"], + text_color=T["text"]) + ent.pack(side="left") + ent.insert(0, self.settings.get("export_paths", {}).get(key, DEFAULT_EXPORT_PATHS[key])) + + def _br(e=ent): + p = filedialog.askdirectory() + if p: + e.delete(0, "end") + e.insert(0, p) + self._btn(rf, text="", command=_br, image=self.icons.browse, + width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"], + border_width=1, border_color=T["border"] + ).pack(side="left", padx=(4, 0)) + pe[key] = ent + wg["paths"] = pe + + # --- Buttons --- + bf = ctk.CTkFrame(w, fg_color="transparent") + bf.pack(fill="x", padx=12, pady=(4, 12)) + + self._btn(bf, text="Abbrechen", command=w.destroy, width=110, height=34, + fg_color=T["btn2"], hover_color=T["btn2_h"], + border_width=1, border_color=T["border"], + text_color=T["text2"] + ).pack(side="left") + + self._btn(bf, text=" Speichern", command=lambda: self._save_s(wg, w), + image=self.icons.save, width=140, height=34, + fg_color=T["accent"], hover_color=T["accent_h"], + text_color="#000000", font=ctk.CTkFont(weight="bold") + ).pack(side="right") + + w.after(50, lambda: (w.lift(), w.focus_force())) + if not IS_MACOS: + w.after(200, lambda: w.grab_set()) + + def _save_s(self, wg, win): + try: + th = int(wg["threads"].get()) + if th < 0: + messagebox.showerror("Fehler", "Threads >= 0") + return + except ValueError: + messagebox.showerror("Fehler", "UngĂŒltige Zahl") + return + + self.settings["threads"] = th + self.settings["appearance_mode"] = wg["appearance"].get() + self.settings["check_updates_on_start"] = wg["auto_update"].get() + self.settings["audio"] = { + "output_format": wg["fmt"].get(), + "encoding_mode": wg["mode"].get(), + "bitrate": wg["br"].get(), + "vbr_quality": wg["vbr"].get(), + "sample_rate": wg["sr"].get(), + "channels": wg["ch"].get(), + "overwrite_existing": wg["ow"].get(), + } + self.settings["metadata"] = { + "artist": wg["m_artist"].get(), + "cover": wg["m_cover"].get(), + "auto_track_number": wg["auto_track"].get(), + "auto_album_from_folder": wg["auto_album"].get(), + } + self.settings["export_paths"] = {k: v.get() for k, v in wg["paths"].items()} + save_settings(self.settings) + self.audio_lbl.configure(text=self._audio_summary(self.settings["audio"])) + win.destroy() + self.after(30, self._activate) + + # ================================================================= + # Update + # ================================================================= + + def _check_updates(self): + self.update_btn.configure(state="disabled", text=" PrĂŒfe...") + self._set_status("Suche Updates...", T["warn"]) + + def _do(): + r = check_for_updates() + self.after(0, lambda: self._upd_done(r)) + threading.Thread(target=_do, daemon=True).start() + + def _upd_done(self, rel): + self.update_btn.configure(state="normal", text=" Update prĂŒfen") + if not rel: + self._set_status("Neueste Version", T["success"]) + return + tag = rel.get("tag_name", "?") + if messagebox.askyesno("Update", f"Version {tag} verfĂŒgbar.\nJetzt installieren?"): + self._do_update(rel) + self.after(30, self._activate) + + def _do_update(self, rel): + self.update_btn.configure(state="disabled", text=" LĂ€dt...") + def _p(pct, msg): + self.after(0, lambda: self._set_status(msg, T["warn"])) + self.after(0, lambda: self.total_bar.set(pct / 100)) + def _go(): + ok = download_and_install_update(rel, progress_callback=_p) + self.after(0, lambda: self._upd_fin(ok)) + threading.Thread(target=_go, daemon=True).start() + + def _upd_fin(self, ok): + self.update_btn.configure(state="normal", text=" Update prĂŒfen") + if ok: + messagebox.showinfo("Update", "Bitte neu starten.") + else: + self._set_status("Update fehlgeschlagen", T["error"]) + self.after(30, self._activate) + + # ================================================================= + # Helfer + # ================================================================= + + @staticmethod + def _slabel(parent, text): + ctk.CTkLabel(parent, text=text, + font=ctk.CTkFont(size=14, weight="bold"), + text_color=T["text"]).pack(anchor="w", pady=(10, 2)) + + def _on_close(self): + if self.is_converting: + if messagebox.askyesno("Beenden?", "Konvertierung lĂ€uft. Wirklich beenden?"): + self.destroy() + else: + self.destroy() diff --git a/fcco_converter/icons.py b/fcco_converter/icons.py new file mode 100644 index 0000000..5022a28 --- /dev/null +++ b/fcco_converter/icons.py @@ -0,0 +1,304 @@ +""" +fcco-Converter V2 – Icon-Modul + +Erzeugt saubere, professionelle Icons programmatisch mit PIL. +Keine externen Bilddateien nötig – alle Icons sind Vektorgrafiken, +die in beliebiger GrĂ¶ĂŸe gerendert werden können. +""" + +from PIL import Image, ImageDraw +import customtkinter as ctk + + +def _create_canvas(size: int = 24, bg: str | None = None) -> tuple[Image.Image, ImageDraw.ImageDraw]: + """Erstellt eine transparente ZeichenflĂ€che.""" + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img, "RGBA") + return img, draw + + +def _hex_to_rgba(hex_color: str, alpha: int = 255) -> tuple[int, int, int, int]: + """Konvertiert einen Hex-Farbwert zu RGBA.""" + hex_color = hex_color.lstrip("#") + r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16) + return (r, g, b, alpha) + + +def make_ctk_image(light_img: Image.Image, dark_img: Image.Image | None = None, size: tuple[int, int] = (20, 20)) -> ctk.CTkImage: + """Erzeugt ein CTkImage aus PIL-Bildern.""" + return ctk.CTkImage( + light_image=light_img, + dark_image=dark_img or light_img, + size=size, + ) + + +# ========================================================================= +# Icon-Generatoren +# ========================================================================= + +def icon_folder(color: str = "#e2e8f0", size: int = 24) -> Image.Image: + """Ordner-Icon.""" + img, draw = _create_canvas(size) + c = _hex_to_rgba(color) + s = size + # Ordner-Körper + draw.rounded_rectangle( + [s * 0.08, s * 0.25, s * 0.92, s * 0.85], + radius=s * 0.08, fill=c + ) + # Tab oben + draw.rounded_rectangle( + [s * 0.08, s * 0.15, s * 0.45, s * 0.35], + radius=s * 0.06, fill=c + ) + return img + + +def icon_file_add(color: str = "#e2e8f0", size: int = 24) -> Image.Image: + """Datei-hinzufĂŒgen-Icon.""" + img, draw = _create_canvas(size) + c = _hex_to_rgba(color) + s = size + lw = max(1, int(s * 0.08)) + # Dokument + draw.rounded_rectangle( + [s * 0.17, s * 0.04, s * 0.71, s * 0.96], + radius=s * 0.06, outline=c, width=lw + ) + # Plus-Zeichen + cx, cy = s * 0.44, s * 0.50 + arm = s * 0.15 + draw.line([(cx - arm, cy), (cx + arm, cy)], fill=c, width=lw) + draw.line([(cx, cy - arm), (cx, cy + arm)], fill=c, width=lw) + return img + + +def icon_play(color: str = "#e2e8f0", size: int = 24) -> Image.Image: + """Play/Start-Icon (Dreieck).""" + img, draw = _create_canvas(size) + c = _hex_to_rgba(color) + s = size + # Dreieck (nach rechts zeigend) + points = [ + (s * 0.25, s * 0.15), + (s * 0.80, s * 0.50), + (s * 0.25, s * 0.85), + ] + draw.polygon(points, fill=c) + return img + + +def icon_settings(color: str = "#e2e8f0", size: int = 24) -> Image.Image: + """Zahnrad/Einstellungen-Icon.""" + img, draw = _create_canvas(size) + c = _hex_to_rgba(color) + s = size + lw = max(1, int(s * 0.08)) + cx, cy = s * 0.5, s * 0.5 + # Äußerer Ring + r_outer = s * 0.38 + draw.ellipse( + [cx - r_outer, cy - r_outer, cx + r_outer, cy + r_outer], + outline=c, width=lw + ) + # Innerer Punkt + r_inner = s * 0.12 + draw.ellipse( + [cx - r_inner, cy - r_inner, cx + r_inner, cy + r_inner], + fill=c + ) + # ZĂ€hne (6 StĂŒck als kurze Linien) + import math + for i in range(6): + angle = math.pi * i / 3 + x1 = cx + r_outer * math.cos(angle) + y1 = cy + r_outer * math.sin(angle) + x2 = cx + (r_outer + s * 0.12) * math.cos(angle) + y2 = cy + (r_outer + s * 0.12) * math.sin(angle) + draw.line([(x1, y1), (x2, y2)], fill=c, width=lw) + return img + + +def icon_refresh(color: str = "#e2e8f0", size: int = 24) -> Image.Image: + """Refresh/Update-Icon (kreisförmiger Pfeil).""" + img, draw = _create_canvas(size) + c = _hex_to_rgba(color) + s = size + lw = max(1, int(s * 0.08)) + # Kreisbogen + draw.arc( + [s * 0.15, s * 0.15, s * 0.85, s * 0.85], + start=30, end=330, fill=c, width=lw + ) + # Pfeilspitze + import math + angle = math.radians(30) + cx, cy = s * 0.5, s * 0.5 + r = s * 0.35 + tip_x = cx + r * math.cos(angle) + tip_y = cy - r * math.sin(angle) + arrow_size = s * 0.15 + points = [ + (tip_x, tip_y), + (tip_x + arrow_size, tip_y + arrow_size * 0.3), + (tip_x + arrow_size * 0.3, tip_y + arrow_size), + ] + draw.polygon(points, fill=c) + return img + + +def icon_save(color: str = "#e2e8f0", size: int = 24) -> Image.Image: + """Speichern-Icon (Diskette).""" + img, draw = _create_canvas(size) + c = _hex_to_rgba(color) + s = size + lw = max(1, int(s * 0.08)) + # Äußeres Rechteck + draw.rounded_rectangle( + [s * 0.10, s * 0.10, s * 0.90, s * 0.90], + radius=s * 0.06, outline=c, width=lw + ) + # Inneres Rechteck (Etikett) + draw.rectangle( + [s * 0.30, s * 0.55, s * 0.70, s * 0.85], + outline=c, width=lw + ) + # Oberer Bereich (Schieber) + draw.rectangle( + [s * 0.30, s * 0.10, s * 0.70, s * 0.38], + fill=c + ) + return img + + +def icon_browse(color: str = "#e2e8f0", size: int = 24) -> Image.Image: + """Durchsuchen-Icon (Lupe).""" + img, draw = _create_canvas(size) + c = _hex_to_rgba(color) + s = size + lw = max(1, int(s * 0.08)) + # Kreis + draw.ellipse( + [s * 0.12, s * 0.08, s * 0.62, s * 0.58], + outline=c, width=lw + ) + # Griff + draw.line( + [(s * 0.55, s * 0.55), (s * 0.88, s * 0.88)], + fill=c, width=max(2, int(s * 0.10)) + ) + return img + + +def icon_check(color: str = "#34d399", size: int = 24) -> Image.Image: + """HĂ€kchen-Icon.""" + img, draw = _create_canvas(size) + c = _hex_to_rgba(color) + s = size + lw = max(2, int(s * 0.10)) + draw.line( + [(s * 0.18, s * 0.52), (s * 0.40, s * 0.75), (s * 0.82, s * 0.25)], + fill=c, width=lw, joint="curve" + ) + return img + + +def icon_cross(color: str = "#f87171", size: int = 24) -> Image.Image: + """Kreuz/Fehler-Icon.""" + img, draw = _create_canvas(size) + c = _hex_to_rgba(color) + s = size + lw = max(2, int(s * 0.10)) + draw.line([(s * 0.22, s * 0.22), (s * 0.78, s * 0.78)], fill=c, width=lw) + draw.line([(s * 0.78, s * 0.22), (s * 0.22, s * 0.78)], fill=c, width=lw) + return img + + +def icon_music(color: str = "#e2e8f0", size: int = 24) -> Image.Image: + """Musik/Audio-Icon (Notensymbol).""" + img, draw = _create_canvas(size) + c = _hex_to_rgba(color) + s = size + lw = max(1, int(s * 0.08)) + # Notenhals + draw.line([(s * 0.60, s * 0.15), (s * 0.60, s * 0.70)], fill=c, width=lw) + # Notenkopf + draw.ellipse( + [s * 0.35, s * 0.60, s * 0.65, s * 0.85], + fill=c + ) + # FĂ€hnchen + draw.arc( + [s * 0.55, s * 0.12, s * 0.88, s * 0.45], + start=270, end=90, fill=c, width=lw + ) + return img + + +def icon_info(color: str = "#e2e8f0", size: int = 24) -> Image.Image: + """Info-Icon (i im Kreis).""" + img, draw = _create_canvas(size) + c = _hex_to_rgba(color) + s = size + lw = max(1, int(s * 0.08)) + # Kreis + draw.ellipse( + [s * 0.10, s * 0.10, s * 0.90, s * 0.90], + outline=c, width=lw + ) + # Punkt oben + draw.ellipse( + [s * 0.44, s * 0.22, s * 0.56, s * 0.34], + fill=c + ) + # Strich unten + draw.line([(s * 0.50, s * 0.42), (s * 0.50, s * 0.75)], fill=c, width=lw) + return img + + +def icon_download(color: str = "#e2e8f0", size: int = 24) -> Image.Image: + """Download-Icon (Pfeil nach unten).""" + img, draw = _create_canvas(size) + c = _hex_to_rgba(color) + s = size + lw = max(1, int(s * 0.08)) + # Vertikale Linie + draw.line([(s * 0.50, s * 0.12), (s * 0.50, s * 0.62)], fill=c, width=lw) + # Pfeilspitze + draw.line([(s * 0.30, s * 0.48), (s * 0.50, s * 0.68)], fill=c, width=lw) + draw.line([(s * 0.70, s * 0.48), (s * 0.50, s * 0.68)], fill=c, width=lw) + # Unterstrich + draw.line([(s * 0.20, s * 0.85), (s * 0.80, s * 0.85)], fill=c, width=lw) + return img + + +# ========================================================================= +# Vorgefertigte CTkImage-Sets (Light + Dark) +# ========================================================================= + +class IconSet: + """HĂ€lt alle Icons als CTkImage-Instanzen. + Muss nach CTk-Initialisierung erstellt werden. + """ + + def __init__(self, size: tuple[int, int] = (18, 18)): + s = size[0] + light = "#3f3f46" # FĂŒr hellen Modus + dark = "#e2e8f0" # FĂŒr dunklen Modus + accent = "#8b7cf6" + success = "#34d399" + error = "#f87171" + + self.folder = make_ctk_image(icon_folder(light, s), icon_folder(dark, s), size) + self.file_add = make_ctk_image(icon_file_add(light, s), icon_file_add(dark, s), size) + self.play = make_ctk_image(icon_play(light, s), icon_play(dark, s), size) + self.settings = make_ctk_image(icon_settings(light, s), icon_settings(dark, s), size) + self.refresh = make_ctk_image(icon_refresh(light, s), icon_refresh(dark, s), size) + self.save = make_ctk_image(icon_save(light, s), icon_save(dark, s), size) + self.browse = make_ctk_image(icon_browse(light, s), icon_browse(dark, s), size) + self.check = make_ctk_image(icon_check(success, s), icon_check(success, s), size) + self.cross = make_ctk_image(icon_cross(error, s), icon_cross(error, s), size) + self.music = make_ctk_image(icon_music(light, s), icon_music(dark, s), size) + self.info = make_ctk_image(icon_info(light, s), icon_info(dark, s), size) + self.download = make_ctk_image(icon_download(light, s), icon_download(dark, s), size) diff --git a/fcco_converter/metadata.py b/fcco_converter/metadata.py new file mode 100644 index 0000000..e7a4fba --- /dev/null +++ b/fcco_converter/metadata.py @@ -0,0 +1,88 @@ +""" +fcco-Converter V2 – Metadaten-Modul + +Hilfsfunktionen fĂŒr ID3-Tags, Cover-Bilder und Albumname-Generierung. +""" + +from datetime import datetime +from pathlib import Path + +from mutagen.easyid3 import EasyID3 +from mutagen.id3 import ID3, APIC + +from .config import WEEKDAY_SHORT_DE + + +def generate_album_name(veranstaltung: str = "") -> str: + """Erstellt einen Albumnamen basierend auf dem aktuellen Datum und der Veranstaltung.""" + date_str = datetime.now().strftime("%d.%m.%Y") + if veranstaltung: + return f"{date_str} {veranstaltung}" + return date_str + + +def generate_folder_name(date_part: str, veranstaltung: str) -> str: + """Erstellt den Ordnernamen fĂŒr den Export.""" + if veranstaltung: + return f"{date_part} {veranstaltung}" + return date_part + + +def generate_date_part() -> str: + """Erstellt das DatumskĂŒrzel (z.B. '260719Sa') fĂŒr die aktuelle Zeit.""" + now = datetime.now() + return now.strftime("%y%m%d") + WEEKDAY_SHORT_DE[now.weekday()] + + +def extract_event_from_folder(file_path: Path) -> tuple[str, str]: + """Versucht, Datum und Veranstaltung aus dem Ordnernamen zu extrahieren. + + Erwartetes Format: 'YYMMDDWt Veranstaltungsname' + z.B. '260406Mo Ostermontag' + + Returns: + (date_part, veranstaltung) oder ("", "") wenn nicht erkannt. + """ + folder_name = file_path.parent.name + if len(folder_name) >= 8 and folder_name[:6].isdigit() and folder_name[6:8] in WEEKDAY_SHORT_DE: + date_part = folder_name[:8] + veranstaltung = folder_name[8:].strip() if len(folder_name) > 8 else "" + return date_part, veranstaltung + return "", "" + + +def apply_id3_tags( + mp3_path: Path, + title: str, + artist: str, + album: str, + track_number: int, + year: str, + cover_path: Path | None = None, +) -> None: + """Schreibt ID3-Metadaten und optional ein Cover in die MP3-Datei.""" + audiofile = EasyID3(str(mp3_path)) + audiofile["title"] = title + audiofile["artist"] = artist or "Unbekannt" + audiofile["album"] = album or "Unbekanntes Album" + audiofile["tracknumber"] = str(track_number) + audiofile["date"] = year + audiofile.save() + + if cover_path and cover_path.exists(): + audio_id3 = ID3(str(mp3_path)) + mime = "image/jpeg" + suffix = cover_path.suffix.lower() + if suffix == ".png": + mime = "image/png" + with open(cover_path, "rb") as img: + audio_id3.add( + APIC( + encoding=3, + mime=mime, + type=3, + desc="Cover", + data=img.read(), + ) + ) + audio_id3.save() diff --git a/fcco_converter/updater.py b/fcco_converter/updater.py new file mode 100644 index 0000000..b286062 --- /dev/null +++ b/fcco_converter/updater.py @@ -0,0 +1,225 @@ +""" +fcco-Converter V2 – Update-Modul + +PrĂŒft auf neue Releases auf dem Gitea-Server und lĂ€dt diese herunter. +UnterstĂŒtzt Self-Update fĂŒr Standalone-Builds (PyInstaller). +""" + +import os +import sys +import platform +import tempfile +import zipfile +import shutil +import subprocess +from pathlib import Path +from typing import Callable +from urllib.request import urlopen, Request +from urllib.error import URLError +import json + +# Aktuelle Version des Programms +CURRENT_VERSION = "2.0.0" + +# Gitea API-URL fĂŒr Releases +GITEA_API_BASE = "https://git.joelunger.de/api/v1" +REPO_OWNER = "public" +REPO_NAME = "fcco-Converter" +RELEASES_URL = f"{GITEA_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/releases" + + +def get_current_version() -> str: + """Gibt die aktuelle Programmversion zurĂŒck.""" + return CURRENT_VERSION + + +def _fetch_json(url: str) -> list | dict: + """Holt JSON-Daten von einer URL.""" + req = Request(url, headers={"Accept": "application/json"}) + with urlopen(req, timeout=15) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def check_for_updates() -> dict | None: + """PrĂŒft, ob ein neues Release verfĂŒgbar ist. + + Returns: + Dict mit Release-Infos (tag_name, name, body, assets) oder None. + """ + try: + releases = _fetch_json(RELEASES_URL) + if not releases: + return None + + # Neuestes (nicht als Draft/Prerelease markiertes) Release + for release in releases: + if release.get("draft") or release.get("prerelease"): + continue + + tag = release.get("tag_name", "").lstrip("v") + if tag and tag != CURRENT_VERSION: + # Einfacher Versionsvergleich + try: + remote_parts = [int(x) for x in tag.split(".")] + local_parts = [int(x) for x in CURRENT_VERSION.split(".")] + if remote_parts > local_parts: + return release + except ValueError: + continue + break # Nur das neueste Release prĂŒfen + + return None + except (URLError, json.JSONDecodeError, OSError): + return None + + +def _get_platform_asset_name() -> str: + """Ermittelt den erwarteten Asset-Dateinamen fĂŒr das aktuelle Betriebssystem.""" + system = platform.system().lower() + if system == "windows": + return "fcco-Converter-windows.zip" + elif system == "darwin": + return "fcco-Converter-macos.zip" + else: + return "fcco-Converter-linux.zip" + + +def _find_download_url(release: dict) -> str | None: + """Findet die Download-URL fĂŒr das passende Plattform-Asset.""" + expected_name = _get_platform_asset_name() + for asset in release.get("assets", []): + if asset.get("name", "").lower() == expected_name.lower(): + return asset.get("browser_download_url") + return None + + +def download_and_install_update( + release: dict, + progress_callback: Callable[[int, str], None] | None = None, +) -> bool: + """LĂ€dt das neueste Release herunter und installiert es. + + FĂŒr Standalone-Builds (PyInstaller): Ersetzt die aktuelle Executable. + FĂŒr Skript-Modus: Entpackt in das Projektverzeichnis. + + Args: + release: Release-Dict von der Gitea-API. + progress_callback: Callback(percentage, status_message). + + Returns: + True bei Erfolg, False bei Fehler. + """ + download_url = _find_download_url(release) + if not download_url: + if progress_callback: + progress_callback(0, "Kein passendes Asset fĂŒr dieses Betriebssystem gefunden.") + return False + + try: + if progress_callback: + progress_callback(10, "Download wird gestartet...") + + # Download in temporĂ€res Verzeichnis + tmp_dir = Path(tempfile.mkdtemp(prefix="fcco_update_")) + zip_path = tmp_dir / "update.zip" + + req = Request(download_url) + with urlopen(req, timeout=120) as resp: + total_size = int(resp.headers.get("Content-Length", 0)) + downloaded = 0 + chunk_size = 8192 + + with open(zip_path, "wb") as f: + while True: + chunk = resp.read(chunk_size) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + if total_size > 0 and progress_callback: + pct = min(int((downloaded / total_size) * 70) + 10, 80) + progress_callback(pct, f"Heruntergeladen: {downloaded // 1024} KB") + + if progress_callback: + progress_callback(85, "Entpacken...") + + # Entpacken + extract_dir = tmp_dir / "extracted" + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall(extract_dir) + + if progress_callback: + progress_callback(90, "Installation...") + + # Bestimme, wo das Programm lĂ€uft + if getattr(sys, "frozen", False): + # PyInstaller-Bundle: Ersetze die aktuelle Executable + current_exe = Path(sys.executable) + _install_frozen(extract_dir, current_exe) + else: + # Skript-Modus: Ersetze die Projektdateien + project_dir = Path(__file__).parent.parent + _install_script(extract_dir, project_dir) + + if progress_callback: + progress_callback(100, "Update abgeschlossen! Bitte Programm neu starten.") + + return True + + except Exception as e: + if progress_callback: + progress_callback(0, f"Fehler: {e}") + return False + finally: + # AufrĂ€umen (best-effort) + try: + shutil.rmtree(tmp_dir, ignore_errors=True) + except Exception: + pass + + +def _install_frozen(extract_dir: Path, current_exe: Path) -> None: + """Installiert ein Update fĂŒr eine PyInstaller-gepackte Anwendung.""" + # Suche die neue Executable im entpackten Verzeichnis + system = platform.system().lower() + if system == "windows": + new_exe_name = "fcco-Converter.exe" + elif system == "darwin": + new_exe_name = "fcco-Converter" + else: + new_exe_name = "fcco-Converter" + + new_exe = None + for item in extract_dir.rglob(new_exe_name): + new_exe = item + break + + if not new_exe: + raise FileNotFoundError(f"Neue Programmdatei '{new_exe_name}' nicht im Update gefunden.") + + # Auf Windows: Aktuelle Exe umbenennen, dann neue kopieren + backup = current_exe.with_suffix(".old") + if backup.exists(): + backup.unlink() + + if system == "windows": + current_exe.rename(backup) + shutil.copy2(new_exe, current_exe) + else: + # macOS/Linux: Direkt ersetzen + shutil.copy2(new_exe, current_exe) + os.chmod(current_exe, 0o755) + + +def _install_script(extract_dir: Path, project_dir: Path) -> None: + """Installiert ein Update im Skript-Modus (Quelldateien ersetzen).""" + # Alle .py-Dateien aus dem entpackten Verzeichnis kopieren + for item in extract_dir.rglob("*"): + if item.is_file(): + rel = item.relative_to(extract_dir) + # Überspringe versteckte Dateien und Verzeichnisse + if any(part.startswith(".") for part in rel.parts): + continue + dest = project_dir / rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, dest) diff --git a/main.py b/main.py index cdfe9a4..6d3dffc 100644 --- a/main.py +++ b/main.py @@ -1,450 +1,30 @@ -import os -import json -import ctypes -import threading -import subprocess -from datetime import datetime -from tkinter import filedialog, messagebox, simpledialog, Toplevel, Button -from tkinter import * -from tkinter.ttk import Progressbar -from concurrent.futures import ThreadPoolExecutor, as_completed +#!/usr/bin/env python3 +""" +fcco-Converter V2 – Einstiegspunkt -try: - from mutagen.easyid3 import EasyID3 - from mutagen.id3 import ID3, APIC -except ImportError: - messagebox.showerror("Fehler", "Die Bibliothek 'mutagen' ist nicht installiert. Bitte installieren Sie sie mit 'pip install mutagen'.") - exit() +Startet die grafische BenutzeroberflĂ€che. +""" -SETTINGS_FILE = "settings.json" -HISTORY_FILE = "history.json" +import sys +import platform -WEEKDAY_SHORT_DE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] - -DEFAULT_EXPORT_PATHS = { - "Gottesdienst": "gottesdienste", - "Hochzeit": "hochzeiten", - "Weissagung": "weissagungen", - "Jugend": "jugend" -} - -def load_settings(): - if os.path.exists(SETTINGS_FILE): - with open(SETTINGS_FILE, "r") as f: - return json.load(f) - return { - "export_paths": DEFAULT_EXPORT_PATHS.copy(), - "threads": 0 # 0 = auto, use all available cores - } - -def save_settings(settings): - with open(SETTINGS_FILE, "w") as f: - json.dump(settings, f, indent=2) - -def save_export_history(entry): - history = [] - if os.path.exists(HISTORY_FILE): - with open(HISTORY_FILE, "r") as f: - history = json.load(f) - history.append(entry) - with open(HISTORY_FILE, "w") as f: - json.dump(history, f, indent=2) - -# Fix pixelation on high-DPI displays -try: - ctypes.windll.shcore.SetProcessDpiAwareness(2) # Per-monitor DPI awareness -except: +# DPI-Awareness auf Windows setzen (vor dem GUI-Import) +if platform.system() == "Windows": try: - ctypes.windll.user32.SetProcessDPIAware() # Fallback for older Windows - except: - pass - -settings = load_settings() - -root = Tk() -root.iconbitmap("icon.ico") -root.title("fcco Converter") -root.geometry("800x1200") - -Label(root, text="WĂ€hle WAV-Dateien oder Ordner aus:").pack(pady=5) -file_listbox = Listbox(root, width=60) -file_listbox.pack(pady=5) - -file_status_listbox = Listbox(root, width=60) -file_status_listbox.pack(pady=5) - -progress_bars = [] # List to store per-file progress bars - -file_entries = [] # List of dicts: {'full_path': str, 'rel_dir': str} -base_folder = None # Set when folder is selected -is_converting = False # Track conversion status -conversion_running = False # Prevent multiple conversions - -def choose_files(): - global file_entries, base_folder - paths = filedialog.askopenfilenames(filetypes=[("WAV-Dateien", "*.wav")]) - if paths: - base_folder = None - file_entries = [{'full_path': p, 'rel_dir': ''} for p in paths] - update_file_list() - -def choose_folder(): - global file_entries, base_folder - folder = filedialog.askdirectory() - if folder: - base_folder = folder - file_entries = [] - for root_dir, _, files in os.walk(folder): - for f in files: - if f.lower().endswith('.wav'): - full_path = os.path.join(root_dir, f) - rel_path = os.path.relpath(root_dir, folder) - file_entries.append({'full_path': full_path, 'rel_dir': rel_path}) - update_file_list() - -def update_file_list(): - file_listbox.delete(0, END) - file_status_listbox.delete(0, END) - for bar in progress_bars: - bar.destroy() - progress_bars.clear() - for entry in file_entries: - basename = os.path.basename(entry['full_path']) - display_name = os.path.join(entry['rel_dir'], basename) if entry['rel_dir'] else basename - file_listbox.insert(END, display_name) - file_status_listbox.insert(END, f"0% – {basename} – Wartet...") - progress = Progressbar(root, orient=HORIZONTAL, length=400, mode='determinate') - progress.pack(pady=2) - progress_bars.append(progress) - -Button(root, text="Dateien auswĂ€hlen", command=choose_files).pack(pady=5) -Button(root, text="Ordner auswĂ€hlen", command=choose_folder).pack(pady=5) - -def generate_album_name(veranstaltung=""): - return datetime.now().strftime("%d.%m.%Y") + (f" {veranstaltung}" if veranstaltung else "") - -def generate_folder_name(date_part, veranstaltung): - return f"{date_part} {veranstaltung}" if veranstaltung else date_part - -def extract_event_from_folder(file_path): - folder_name = os.path.basename(os.path.dirname(file_path)) - if len(folder_name) >= 8 and folder_name[:6].isdigit() and folder_name[6:8] in WEEKDAY_SHORT_DE: - return folder_name[:8], folder_name[8:].strip() if len(folder_name) > 8 else "" - return "", "" - -def open_settings_window(): - win = Toplevel(root) - win.iconbitmap("icon.ico") - win.title("Einstellungen") - win.geometry("500x600") - - Label(win, text="KĂŒnstlername:").pack() - artist_entry = Entry(win, width=40) - artist_entry.pack() - artist_entry.insert(0, settings.get("metadata", {}).get("artist", "")) - - Label(win, text="Cover Pfad:").pack() - cover_entry = Entry(win, width=40) - cover_entry.pack() - cover_entry.insert(0, settings.get("metadata", {}).get("cover", "")) - - Label(win, text="Anzahl Threads (0 = Auto):").pack(pady=10) - threads_entry = Entry(win, width=40) - threads_entry.pack() - threads_entry.insert(0, str(settings.get("threads", 0))) - - Label(win, text="Standardverzeichnisse fĂŒr Exporte:").pack(pady=10) - - entries = {} - for key in DEFAULT_EXPORT_PATHS: - Label(win, text=key).pack() - entry = Entry(win, width=40) - entry.pack() - entry.insert(0, settings.get("export_paths", {}).get(key, DEFAULT_EXPORT_PATHS[key])) - entries[key] = entry - - def save_all(): + import ctypes + ctypes.windll.shcore.SetProcessDpiAwareness(2) + except Exception: try: - threads = int(threads_entry.get()) - if threads < 0: - messagebox.showerror("Fehler", "Threads mĂŒssen 0 oder grĂ¶ĂŸer sein.") - return - except ValueError: - messagebox.showerror("Fehler", "Bitte geben Sie eine gĂŒltige Zahl fĂŒr Threads ein.") - return + ctypes.windll.user32.SetProcessDPIAware() + except Exception: + pass - settings["metadata"] = { - "artist": artist_entry.get(), - "cover": cover_entry.get() - } - settings["export_paths"] = {k: v.get() for k, v in entries.items()} - settings["threads"] = threads - save_settings(settings) - messagebox.showinfo("Gespeichert", "Einstellungen wurden gespeichert.") - win.destroy() - Button(win, text="Speichern", command=save_all).pack(pady=10) +def main(): + from fcco_converter.gui import App + app = App() + app.mainloop() -Button(root, text="Einstellungen öffnen", command=open_settings_window).pack(pady=5) -def confirm_metadata_and_type(): - meta = settings.get("metadata", {}).copy() - if file_entries: - date_part, veranstaltung = extract_event_from_folder(file_entries[0]['full_path']) - if not date_part: - date_part = datetime.now().strftime("%y%m%d") + WEEKDAY_SHORT_DE[datetime.now().weekday()] - else: - date_part = datetime.now().strftime("%y%m%d") + WEEKDAY_SHORT_DE[datetime.now().weekday()] - veranstaltung = "" - meta["album"] = generate_album_name(veranstaltung) - meta["year"] = str(datetime.now().year) - - win = Toplevel(root) - win.iconbitmap("icon.ico") - win.title("Metadaten und Exportoptionen") - win.geometry("650x650") - - Label(win, text="KĂŒnstlername:").pack() - artist_entry = Entry(win, width=40) - artist_entry.pack() - artist_entry.insert(0, meta.get("artist", "")) - - Label(win, text="Albumname:").pack() - album_entry = Entry(win, width=40) - album_entry.pack() - album_entry.insert(0, meta.get("album", "")) - - Label(win, text="Jahr:").pack() - year_entry = Entry(win, width=40) - year_entry.pack() - year_entry.insert(0, meta.get("year", "")) - - Label(win, text="Cover-Pfad:").pack() - cover_entry = Entry(win, width=40) - cover_entry.pack() - cover_entry.insert(0, meta.get("cover", "")) - - Label(win, text="Art des Inhalts:").pack(pady=10) - content_type = StringVar(value="Gottesdienst") - for option in ["Gottesdienst", "Hochzeit", "Weissagung", "Jugend", "Sonstiges"]: - Radiobutton(win, text=option, variable=content_type, value=option).pack(anchor=W) - - date_event_frame = Frame(win) - date_event_frame.pack(pady=10) - - Label(date_event_frame, text="Datum:").grid(row=0, column=0, sticky="ew") - date_entry = Entry(date_event_frame, width=20) - date_entry.grid(row=1, column=0, padx=5, pady=5) - date_entry.insert(0, date_part) - - Label(date_event_frame, text="Veranstaltung (optional):").grid(row=0, column=1, sticky="ew") - event_entry = Entry(date_event_frame, width=20) - event_entry.grid(row=1, column=1, padx=5, pady=5) - event_entry.insert(0, veranstaltung) - - def update_album_name(*args): - event = event_entry.get().strip() - album_entry.delete(0, END) - album_entry.insert(0, generate_album_name(event)) - - event_entry.bind("", update_album_name) - - confirmed_data = {} - - def confirm(): - confirmed_data["artist"] = artist_entry.get() - confirmed_data["album"] = album_entry.get() - confirmed_data["year"] = year_entry.get() - confirmed_data["cover"] = cover_entry.get() - confirmed_data["type"] = content_type.get() - confirmed_data["event"] = event_entry.get().strip() - confirmed_data["date_part"] = date_entry.get().strip() - win.destroy() - - Button(win, text="BestĂ€tigen", command=confirm).pack(pady=10) - win.grab_set() - root.wait_window(win) - - return confirmed_data if confirmed_data else None - -def get_duration(file): - cmd = [ - "ffprobe", "-v", "error", "-show_entries", "format=duration", - "-of", "default=noprint_wrappers=1:nokey=1", file - ] - try: - return float(subprocess.check_output(cmd, universal_newlines=True).strip()) - except: - return 0 - -def convert_file(entry, index, export_dir, meta, use_subdirs): - file = entry['full_path'] - rel_dir = entry['rel_dir'] - try: - title = os.path.splitext(os.path.basename(file))[0] - if use_subdirs and rel_dir: - export_subdir = os.path.join(export_dir, rel_dir) - os.makedirs(export_subdir, exist_ok=True) - mp3_path = os.path.join(export_subdir, f"{title}.mp3") - else: - mp3_path = os.path.join(export_dir, f"{title}.mp3") - - duration = get_duration(file) - if duration == 0: - raise Exception("Could not determine file duration.") - - threads = settings.get("threads", 0) - cmd = [ - "ffmpeg", - "-i", file, - "-threads", str(threads) if threads > 0 else "0", - "-c:a", "libmp3lame", - "-b:a", "192k", - "-y", - mp3_path - ] - - process = subprocess.Popen(cmd, stderr=subprocess.PIPE, universal_newlines=True) - while True: - line = process.stderr.readline() - if not line: - break - if "time=" in line: - time_str = line.split("time=")[1].split(" ")[0] - try: - h, m, s = map(float, time_str.split(':')) - current_time = h * 3600 + m * 60 + s - percentage = min(int((current_time / duration) * 100), 100) - root.after(0, lambda idx=index, pct=percentage, ttl=title: update_progress(idx, pct, ttl, "Konvertiert...")) - except: - pass - - process.wait() - if process.returncode != 0: - raise subprocess.CalledProcessError(process.returncode, cmd) - - audiofile = EasyID3(mp3_path) - audiofile["title"] = title - audiofile["artist"] = meta["artist"] or "Unbekannt" - audiofile["album"] = meta["album"] or "Unbekanntes Album" - audiofile["tracknumber"] = str(index + 1) - audiofile["date"] = meta.get("year", "") - audiofile.save() - - audiofile = ID3(mp3_path) - with open(meta["cover"], "rb") as img: - audiofile.add(APIC( - encoding=3, - mime='image/jpeg', - type=3, - desc=u'Cover', - data=img.read() - )) - audiofile.save() - - return (index, f"{title}.mp3", None) - except subprocess.CalledProcessError as e: - return (index, os.path.basename(file), f"FFmpeg Error: {str(e)}") - except Exception as e: - return (index, os.path.basename(file), str(e)) - -def update_progress(index, percentage, filename, status): - file_status_listbox.delete(index) - file_status_listbox.insert(index, f"{percentage}% – {filename} – {status}") - progress_bars[index]["value"] = percentage - root.update_idletasks() - -def convert(): - global is_converting, conversion_running - if not file_entries or conversion_running: - if not file_entries: - messagebox.showerror("Fehler", "Keine Dateien ausgewĂ€hlt.") - return - - conversion_running = True - result = confirm_metadata_and_type() - if not result: - conversion_running = False - return - - meta = result - content_type = meta["type"] - veranstaltung = meta.get("event", "").strip() - date_part = meta.get("date_part", datetime.now().strftime("%y%m%d") + WEEKDAY_SHORT_DE[datetime.now().weekday()]) - - # Use subdirectories if a folder was selected - use_subdirs = (base_folder is not None) - - if content_type in settings.get("export_paths", {}) and content_type != "Sonstiges": - base_dir = settings["export_paths"][content_type] - os.makedirs(base_dir, exist_ok=True) - folder_name = generate_folder_name(date_part, veranstaltung) - export_dir = os.path.join(base_dir, folder_name) - os.makedirs(export_dir, exist_ok=True) - else: - export_dir = filedialog.askdirectory(title="Export-Ordner wĂ€hlen") - if not export_dir: - conversion_running = False - return - - settings["last_export_dir"] = export_dir - save_settings(settings) - - sorted_entries = sorted(file_entries, key=lambda x: os.path.basename(x['full_path']).lower()) - total = len(sorted_entries) - file_status_listbox.delete(0, END) - for e in sorted_entries: - file_status_listbox.insert(END, f"0% – {os.path.basename(e['full_path'])} – Wartet...") - - def run_conversion(): - global is_converting, conversion_running - is_converting = True - successful_files = [] - max_workers = settings.get("threads", 0) or os.cpu_count() or 1 - - with ThreadPoolExecutor(max_workers=max_workers) as executor: - future_to_file = {executor.submit(convert_file, entry, idx, export_dir, meta, use_subdirs): idx for idx, entry in enumerate(sorted_entries)} - for future in as_completed(future_to_file): - index = future_to_file[future] - result = future.result() - index, filename, error = result - if error: - update_progress(index, 100, filename, f"Fehler: {error}") - else: - update_progress(index, 100, filename, "Fertig!") - successful_files.append(filename) - - save_export_history({ - "datum": datetime.now().strftime("%d.%m.%Y %H:%M"), - "ordner": export_dir, - "dateien": successful_files - }) - - # Custom completion popup with "OK" and "Programm schließen" - win = Toplevel(root) - win.iconbitmap("icon.ico") - win.title("Konvertierung abgeschlossen") - win.geometry("300x150") - Label(win, text="Konvertierung abgeschlossen!", font=("Arial", 12)).pack(pady=20) - Button(win, text="OK", command=win.destroy, width=10).pack(side=LEFT, padx=10, pady=10) - Button(win, text="Programm schließen", command=root.destroy, width=15).pack(side=RIGHT, padx=10, pady=10) - win.grab_set() - - is_converting = False - conversion_running = False - - threading.Thread(target=run_conversion).start() - -def on_closing(): - global is_converting - if is_converting: - if messagebox.askyesno("BestĂ€tigung", "Konvertierung lĂ€uft. Möchten Sie das Programm wirklich beenden?"): - root.destroy() - else: - root.destroy() - -Button(root, text="Konvertieren", command=convert).pack(pady=10) - -root.protocol("WM_DELETE_WINDOW", on_closing) - -root.mainloop() \ No newline at end of file +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/patch_gui.py b/patch_gui.py new file mode 100644 index 0000000..b48a8ca --- /dev/null +++ b/patch_gui.py @@ -0,0 +1,80 @@ +import re +from pathlib import Path + +file_path = Path("fcco_converter/gui.py") +content = file_path.read_text(encoding="utf-8") + +# Helper-Methode hinzufĂŒgen +helper = """ + def _btn(self, parent, text, command, **kwargs): + btn = ctk.CTkButton(parent, text=text, **kwargs) + if IS_MACOS and command: + # macOS Bugfix: Direkter Bind statt Canvas-Command + btn.bind("", lambda e: self.after(10, command)) + elif command: + btn.configure(command=command) + return btn +""" + +if "def _btn(self" not in content: + content = content.replace("def _preload_ffmpeg(self):", helper.lstrip() + "\n def _preload_ffmpeg(self):") + +# Alle ctk.CTkButton(..., command=...) Aufrufe umbauen. +# Das ist per Regex etwas fragil, daher machen wir es manuell fĂŒr die bekannten Stellen: + +replacements = [ + ( + 'self.update_btn = ctk.CTkButton(\n h, text=" Update prĂŒfen", image=self.icons.refresh,\n width=150, height=32,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text2"], font=ctk.CTkFont(size=12),\n command=self._check_updates)', + 'self.update_btn = self._btn(\n h, text=" Update prĂŒfen", command=self._check_updates,\n image=self.icons.refresh, width=150, height=32,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text2"], font=ctk.CTkFont(size=12))' + ), + ( + 'ctk.CTkButton(\n h, text="", image=self.icons.settings,\n width=34, height=32,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n command=self._open_settings\n ).grid', + 'self._btn(\n h, text="", command=self._open_settings,\n image=self.icons.settings, width=34, height=32,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"]\n ).grid' + ), + ( + 'ctk.CTkButton(\n tb, text=" Dateien", image=self.icons.file_add,\n width=130, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text"], font=ctk.CTkFont(size=13),\n command=self._pick_files\n ).grid', + 'self._btn(\n tb, text=" Dateien", command=self._pick_files,\n image=self.icons.file_add, width=130, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text"], font=ctk.CTkFont(size=13)\n ).grid' + ), + ( + 'ctk.CTkButton(\n tb, text=" Ordner", image=self.icons.folder,\n width=130, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text"], font=ctk.CTkFont(size=13),\n command=self._pick_folder\n ).grid', + 'self._btn(\n tb, text=" Ordner", command=self._pick_folder,\n image=self.icons.folder, width=130, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text"], font=ctk.CTkFont(size=13)\n ).grid' + ), + ( + 'self.conv_btn = ctk.CTkButton(\n tb, text=" Konvertieren", image=self.icons.play,\n width=160, height=36,\n fg_color=T["success"], hover_color=T["success_h"],\n text_color="#ffffff", font=ctk.CTkFont(size=14, weight="bold"),\n command=self._convert)', + 'self.conv_btn = self._btn(\n tb, text=" Konvertieren", command=self._convert,\n image=self.icons.play, width=160, height=36,\n fg_color=T["success"], hover_color=T["success_h"],\n text_color="#ffffff", font=ctk.CTkFont(size=14, weight="bold"))' + ), + ( + 'ctk.CTkButton(cover_fr, text="", image=self.icons.browse,\n width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"], command=_bc\n ).grid', + 'self._btn(cover_fr, text="", command=_bc, image=self.icons.browse,\n width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"]\n ).grid' + ), + ( + 'ctk.CTkButton(bf, text="Abbrechen", width=110, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text2"], command=d.destroy\n ).pack', + 'self._btn(bf, text="Abbrechen", command=d.destroy, width=110, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text2"]\n ).pack' + ), + ( + 'ctk.CTkButton(bf, text=" Konvertieren starten",\n image=self.icons.play, width=180, height=34,\n fg_color=T["accent"], hover_color=T["accent_h"],\n text_color="#ffffff", font=ctk.CTkFont(weight="bold"),\n command=_ok\n ).pack', + 'self._btn(bf, text=" Konvertieren starten", command=_ok,\n image=self.icons.play, width=180, height=34,\n fg_color=T["accent"], hover_color=T["accent_h"],\n text_color="#ffffff", font=ctk.CTkFont(weight="bold")\n ).pack' + ), + ( + 'ctk.CTkButton(mcf, text="", image=self.icons.browse,\n width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"], command=_bc2\n ).pack', + 'self._btn(mcf, text="", command=_bc2, image=self.icons.browse,\n width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"]\n ).pack' + ), + ( + 'ctk.CTkButton(rf, text="", image=self.icons.browse,\n width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"], command=_br\n ).pack', + 'self._btn(rf, text="", command=_br, image=self.icons.browse,\n width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"]\n ).pack' + ), + ( + 'ctk.CTkButton(bf, text="Abbrechen", width=110, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text2"], command=w.destroy\n ).pack', + 'self._btn(bf, text="Abbrechen", command=w.destroy, width=110, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text2"]\n ).pack' + ), + ( + 'ctk.CTkButton(bf, text=" Speichern", image=self.icons.save,\n width=140, height=34,\n fg_color=T["accent"], hover_color=T["accent_h"],\n text_color="#ffffff", font=ctk.CTkFont(weight="bold"),\n command=lambda: self._save_s(wg, w)\n ).pack', + 'self._btn(bf, text=" Speichern", command=lambda: self._save_s(wg, w),\n image=self.icons.save, width=140, height=34,\n fg_color=T["accent"], hover_color=T["accent_h"],\n text_color="#ffffff", font=ctk.CTkFont(weight="bold")\n ).pack' + ) +] + +for old, new in replacements: + content = content.replace(old, new) + +file_path.write_text(content, encoding="utf-8") +print("Patch applied.") diff --git a/requirements.txt b/requirements.txt index cf16667..e7bfc0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ -pydub -mutagen -tkinter -datetime \ No newline at end of file +customtkinter>=5.2.0 +mutagen>=1.47.0 +static-ffmpeg>=2.7 +requests>=2.31.0 +Pillow>=10.0.0 +pyinstaller>=6.0 \ No newline at end of file diff --git a/run.bat b/run.bat deleted file mode 100644 index b22e7be..0000000 --- a/run.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -start /min python main.py -exit \ No newline at end of file diff --git a/start.bat b/start.bat deleted file mode 100644 index 413056f..0000000 --- a/start.bat +++ /dev/null @@ -1,6 +0,0 @@ -@echo off -REM Stelle sicher, dass Python 3.11 in deinem PATH ist oder gib den vollstĂ€ndigen Pfad an. -python3.11 main.py - -REM Optional: Das Fenster bleibt offen, damit du eventuelle Fehlermeldungen sehen kannst. -pause \ No newline at end of file diff --git a/test_ctk_btn.py b/test_ctk_btn.py new file mode 100644 index 0000000..9760955 --- /dev/null +++ b/test_ctk_btn.py @@ -0,0 +1,23 @@ +import customtkinter as ctk + +app = ctk.CTk() +app.geometry("400x300") + +def on_click(): + print("Button clicked via command!") + +def on_bind(e): + print("Button clicked via bind!") + +b1 = ctk.CTkButton(app, text="Normal Command", command=on_click) +b1.pack(pady=20) + +b2 = ctk.CTkButton(app, text="Bind Button-1") +b2.bind("", on_bind) +b2.pack(pady=20) + +b3 = ctk.CTkButton(app, text="Bind ButtonRelease-1") +b3.bind("", on_bind) +b3.pack(pady=20) + +app.mainloop()