9 Commits
19 changed files with 2447 additions and 519 deletions
+26
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
.DS_Store
Cover/*
App
+74 -41
View File
@@ -1,62 +1,95 @@
# 🎵 WAV zu MP3 Konverter # fcco Converter V2
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. 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 ## Features
- 📂 Mehrere WAV-Dateien auswählen - Mehrere WAV-Dateien oder ganze Ordner (mit Unterordnern) auswählen
- 📝 Standard-Metadaten definieren (Künstler, Album, Cover) - Metadaten definieren und bearbeiten (Künstler, Album, Jahr, Cover)
- 🧠 Vorschau & Bearbeitung der Metadaten vor der Konvertierung - Automatische Sortierung nach Dateinamen
- 🔄 Automatische Sortierung nach Dateinamen - Cover-Bild einfügen (JPG/PNG)
- 🖼️ Cover-Bild einfügen - Einstellungen werden dauerhaft gespeichert
- 💾 Einstellungen werden gespeichert (Metadaten & letzter Export-Ordner) - Fortschrittsanzeige pro Datei und gesamt
- 📊 Fortschrittsbalken während der Konvertierung - Automatisches Update direkt aus der App
- Moderne, dunkle Benutzeroberfläche (CustomTkinter)
--- ---
## 🛠️ Setup ## Installation (Endnutzer)
# Automatisches Setup starten Lade die neueste Version von der [Releases-Seite](https://git.joelunger.de/public/fcco-Converter/releases) herunter:
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 - **Windows**: `fcco-Converter-windows.zip` entpacken und `fcco-Converter.exe` starten.
1. Stelle sicher, dass Python 3.11 oder neuer installiert ist. - **macOS**: `fcco-Converter-macos.zip` entpacken und `fcco-Converter` starten.
2. Virtuelle Umgebung erstellen und aktivieren:
````python -m venv .venv
.venv\Scripts\activate````
3. Abhängigkeiten installieren: FFmpeg wird automatisch beim ersten Start heruntergeladen. Keine weitere Installation nötig.
`````pip install pydub mutagen````
4. FFmpeg herunterladen und den bin-Ordner zur PATH-Umgebungsvariable hinzufügen. ---
# 🚀 Anwendung starten ## Updates
1. main.py ausführen:
````.venv\Scripts\python.exe main.py````
2. WAV-Dateien auswählen und konvertieren. 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.
---
#📁 Projektstruktur ## Entwicklung
📦 wav-to-mp3-converter ### Voraussetzungen
├── 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 - Python 3.11 oder neuer
Nur .wav-Dateien werden akzeptiert. - Virtuelle Umgebung empfohlen
Cover muss eine .jpg- oder .jpeg-Datei sein. ### Setup
Export erfolgt im .mp3-Format, benannt nach dem Originaldateinamen. ```bash
python -m venv .venv
source .venv/bin/activate # macOS / Linux
# .venv\Scripts\activate # Windows
Metadaten können vor jeder Konvertierung überprüft und angepasst werden. 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.
+117
View File
@@ -0,0 +1,117 @@
#!/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
import shutil
def build():
system = platform.system()
project_dir = Path(__file__).parent
dist_dir = project_dir / "dist"
# Alte App löschen
app_name = "fcco-Converter"
if system == "Darwin":
old_app = dist_dir / f"{app_name}.app"
else:
old_app = dist_dir / f"{app_name}.exe"
if old_app.exists():
if old_app.is_dir():
shutil.rmtree(old_app)
else:
old_app.unlink()
# Basis-Argumente für PyInstaller
args = [
sys.executable,
"-m", "PyInstaller",
"--onefile",
"--windowed",
"--name", app_name,
"--clean",
]
# Icon hinzufügen (wenn vorhanden)
icon_path = project_dir / "icon.ico"
if icon_path.exists():
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:
# Kopiere config.json in den dist Ordner, damit die App sie direkt findet
config_src = project_dir / "config.json"
if config_src.exists():
shutil.copy(config_src, dist_dir / "config.json")
# ZIP-Archiv für das Release erstellen
import tempfile
zip_name = f"fcco-Converter-{system.lower()}"
zip_path = dist_dir / zip_name
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
# App/Exe ins Temp-Verzeichnis kopieren
if system == "Darwin":
shutil.copytree(dist_dir / f"{app_name}.app", tmp / f"{app_name}.app")
else:
shutil.copy(dist_dir / f"{app_name}.exe", tmp / f"{app_name}.exe")
# config.json mit in die ZIP packen
if config_src.exists():
shutil.copy(config_src, tmp / "config.json")
shutil.make_archive(str(zip_path), 'zip', str(tmp))
print()
print(f"Build erfolgreich! Ausgabe in: {dist_dir}")
print(f"ZIP für Release: {zip_path}.zip")
else:
print()
print(f"Build fehlgeschlagen (Code {result.returncode}).")
sys.exit(1)
if __name__ == "__main__":
build()
+7
View File
@@ -0,0 +1,7 @@
{
"app_name": "fcco Converter",
"app_version": "2.0.1",
"repo_owner": "public",
"repo_name": "fcco-Converter",
"gitea_api_base": "https://git.joelunger.de/api/v1"
}
+1
View File
@@ -0,0 +1 @@
# fcco-Converter V2
+190
View File
@@ -0,0 +1,190 @@
"""
fcco-Converter V2 Konfigurationsmodul
Lädt und speichert Anwendungseinstellungen plattformübergreifend.
Verwendet pathlib für alle Pfadoperationen.
"""
import json
import platform
import sys
from pathlib import Path
# ---- Globale App-Informationen (aus config.json) ----
APP_INFO = {
"app_name": "fcco Converter",
"app_version": "2.0.0",
"repo_owner": "public",
"repo_name": "fcco-Converter",
"gitea_api_base": "https://git.joelunger.de/api/v1"
}
def _load_app_info():
try:
# Finde config.json relativ zum Skript oder im PyInstaller Bundle
if getattr(sys, "frozen", False):
# Suche ausgehend von der Executable aufwärts nach config.json
base_dir = Path(sys.executable).parent
while base_dir.name and str(base_dir) != base_dir.root:
if (base_dir / "config.json").exists():
break
base_dir = base_dir.parent
else:
base_dir = Path(__file__).parent.parent
config_path = base_dir / "config.json"
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as f:
data = json.load(f)
APP_INFO.update(data)
except Exception:
pass
_load_app_info()
# =========================================================================
# 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",
}
# 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)
+319
View File
@@ -0,0 +1,319 @@
"""
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 010 (wir mappen 09 auf 110)
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")
# Keine Video/Bilder-Streams übernehmen (verhindert Konflikte mit eigenen Covern)
cmd.append("-vn")
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 (0100) 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,
cover_data: bytes | None = None,
cover_mime: str = "image/jpeg",
) -> 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":
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_data=cover_data,
cover_mime=cover_mime,
)
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 = []
# Cover-Bild einmalig in den Arbeitsspeicher laden, um Dateisperren (Locking)
# bei parallelem Zugriff durch Windows Defender/Antivirus zu vermeiden
cover_data = None
cover_mime = "image/jpeg"
cover_path_str = meta.get("cover")
if cover_path_str:
cp = Path(cover_path_str)
if cp.exists():
if cp.suffix.lower() == ".png":
cover_mime = "image/png"
with open(cp, "rb") as f:
cover_data = f.read()
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,
cover_data=cover_data,
cover_mime=cover_mime,
): 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)
+934
View File
@@ -0,0 +1,934 @@
"""
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,
T,
APP_INFO,
)
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
# =========================================================================
# (Farben werden jetzt aus config.T importiert)
# =========================================================================
# App
# =========================================================================
class App(ctk.CTk):
def __init__(self):
super().__init__()
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
self.title(f"{APP_INFO['app_name']} V{APP_INFO['app_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("<Button-1>", 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("<ButtonRelease-1>", 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["btn2"], hover_color=T["btn2_h"],
text_color="#ffffff", 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["btn2"], hover_color=T["btn2_h"], checkmark_color="#ffffff",
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("<KeyRelease>", _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["btn2"], hover_color=T["btn2_h"],
text_color="#ffffff", 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["border"],
segmented_button_selected_hover_color=T["btn2_h"],
segmented_button_unselected_color=T["elevated"],
segmented_button_unselected_hover_color=T["btn2_h"],
text_color=T["text"])
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["btn2"],
button_hover_color=T["btn2_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["btn2"], hover_color=T["btn2_h"], checkmark_color="#ffffff",
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["btn2"],
button_hover_color=T["btn2_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["btn2"],
button_hover_color=T["btn2_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["btn2"],
button_hover_color=T["btn2_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["btn2"],
button_hover_color=T["btn2_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["btn2"],
button_hover_color=T["btn2_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["btn2"],
button_hover_color=T["btn2_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["btn2"], hover_color=T["btn2_h"], checkmark_color="#ffffff",
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["btn2"], hover_color=T["btn2_h"], checkmark_color="#ffffff",
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["btn2"], hover_color=T["btn2_h"], checkmark_color="#ffffff",
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["btn2"], hover_color=T["btn2_h"],
text_color="#ffffff", 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()
+304
View File
@@ -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)
+90
View File
@@ -0,0 +1,90 @@
"""
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 "", ""
from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TRCK, TDRC
def apply_id3_tags(
mp3_path: Path,
title: str,
artist: str,
album: str,
track_number: int,
year: str,
cover_data: bytes | None = None,
cover_mime: str = "image/jpeg",
) -> None:
"""Schreibt ID3-Metadaten und optional ein Cover in die MP3-Datei."""
try:
tags = ID3(str(mp3_path))
except Exception:
tags = ID3()
tags.add(TIT2(encoding=3, text=title))
tags.add(TPE1(encoding=3, text=artist or "Unbekannt"))
tags.add(TALB(encoding=3, text=album or "Unbekanntes Album"))
tags.add(TRCK(encoding=3, text=str(track_number)))
if year:
tags.add(TDRC(encoding=3, text=year))
if cover_data:
tags.add(
APIC(
encoding=3,
mime=cover_mime,
type=3,
desc="Cover",
data=cover_data,
)
)
tags.save(str(mp3_path), v2_version=3)
+234
View File
@@ -0,0 +1,234 @@
"""
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
from .config import APP_INFO
# Aktuelle Version des Programms und Repos (aus config.json)
CURRENT_VERSION = APP_INFO["app_version"]
GITEA_API_BASE = APP_INFO["gitea_api_base"]
REPO_OWNER = APP_INFO["repo_owner"]
REPO_NAME = APP_INFO["repo_name"]
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 _parse_version(v: str) -> list[int]:
parts = []
for p in v.lstrip("vV").split("."):
try:
parts.append(int(p))
except ValueError:
parts.append(0)
while len(parts) < 3:
parts.append(0)
return parts
def check_for_updates() -> dict | None:
"""Prüft, ob auf Gitea ein neues Release (nach SemVer) vorliegt.
Returns:
Das Release-Dict (von Gitea) wenn ein Update verfügbar ist, sonst None.
"""
try:
req = Request(RELEASES_URL, headers={'User-Agent': 'fcco-Updater/1.0'})
with urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode('utf-8'))
if not data:
return None
# Gitea gibt Releases sortiert zurück, 0 = neuestes
release_info = data[0]
tag_name = release_info.get("tag_name", "")
# Entferne eventuelles "v" Präfix
latest_version = tag_name.lstrip("vV")
# Intelligenter Versions-Vergleich
if _parse_version(latest_version) > _parse_version(CURRENT_VERSION):
return release_info
return None
except Exception as e:
print(f"Update Check Error: {e}")
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)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

+21 -441
View File
@@ -1,450 +1,30 @@
import os #!/usr/bin/env python3
import json """
import ctypes fcco-Converter V2 Einstiegspunkt
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
try: Startet die grafische Benutzeroberfläche.
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()
SETTINGS_FILE = "settings.json" import sys
HISTORY_FILE = "history.json" import platform
WEEKDAY_SHORT_DE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] # DPI-Awareness auf Windows setzen (vor dem GUI-Import)
if platform.system() == "Windows":
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:
try: try:
ctypes.windll.user32.SetProcessDPIAware() # Fallback for older Windows import ctypes
except: ctypes.windll.shcore.SetProcessDpiAwareness(2)
except Exception:
try:
ctypes.windll.user32.SetProcessDPIAware()
except Exception:
pass pass
settings = load_settings()
root = Tk() def main():
root.iconbitmap("icon.ico") from fcco_converter.gui import App
root.title("fcco Converter") app = App()
root.geometry("800x1200") app.mainloop()
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) if __name__ == "__main__":
file_status_listbox.pack(pady=5) main()
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():
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
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)
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("<KeyRelease>", 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()
+80
View File
@@ -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("<ButtonRelease-1>", 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.")
+6 -4
View File
@@ -1,4 +1,6 @@
pydub customtkinter>=5.2.0
mutagen mutagen>=1.47.0
tkinter static-ffmpeg>=2.7
datetime requests>=2.31.0
Pillow>=10.0.0
pyinstaller>=6.0
-3
View File
@@ -1,3 +0,0 @@
@echo off
start /min python main.py
exit
-6
View File
@@ -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
+23
View File
@@ -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("<Button-1>", on_bind)
b2.pack(pady=20)
b3 = ctk.CTkButton(app, text="Bind ButtonRelease-1")
b3.bind("<ButtonRelease-1>", on_bind)
b3.pack(pady=20)
app.mainloop()