Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdf6073cba | ||
|
|
c486339f8d | ||
|
|
87b5ba91b1 | ||
|
|
cb13fd8d2b | ||
|
|
eb222dd2ca | ||
|
|
790cdc9911 | ||
|
|
46429663d9 | ||
|
|
6f8074ff43 | ||
|
|
240779052f | ||
|
|
c2d050ed71 | ||
|
|
293aa5a907 | ||
|
|
d130dd267b |
+26
@@ -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
|
||||||
@@ -1,62 +1,73 @@
|
|||||||
# 🎵 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
|
||||||
---
|
|
||||||
|
---
|
||||||
## 🛠️ Setup
|
|
||||||
|
## Automatische Titel-Extrahierung (Neu in V2.0.2)
|
||||||
# Automatisches Setup starten
|
|
||||||
Windows-Nutzer können einfach ````setup.bat````` ausführen – es installiert:
|
Das Programm erkennt Dateinamen im typischen fcco-Format und erstellt daraus automatisch saubere Songtitel (ID3-Tags).
|
||||||
- Python (falls nicht vorhanden)
|
Das Muster wird nach dem Unterstrich-Format extrahiert. Hier sind Beispiele, wie aus Dateinamen automatisch MP3-Titel gebildet werden:
|
||||||
- Virtuelle Umgebung
|
|
||||||
- Alle benötigten Python-Pakete (pydub, mutagen)
|
- 260719So_N_10_Predigt_D Vorname Nachname.wav -> "Predigt Vorname Nachname"
|
||||||
- FFmpeg (wird automatisch heruntergeladen & eingebunden)
|
- 260719So_N_07_Vorwort_D Vorname Nachname.wav -> "Vorwort Vorname Nachname"
|
||||||
|
- 260719So_N_09_Gedicht_D Vorname Nachname.wav -> "Gedicht Vorname Nachname"
|
||||||
# Alternativ: Manuell installieren
|
- 260719So_N_10_Predigt_D.wav -> "Predigt D"
|
||||||
1. Stelle sicher, dass Python 3.11 oder neuer installiert ist.
|
- 260719So_N_10_Predigt_D Gast.wav -> "Predigt Gast"
|
||||||
2. Virtuelle Umgebung erstellen und aktivieren:
|
- 260719So_N_06_Lied_R О пройденном я подвожу итог.wav -> "О пройденном я подвожу итог"
|
||||||
````python -m venv .venv
|
- 260719So_N_04_Lied_D Zum blauen Himmel.wav -> "Zum blauen Himmel"
|
||||||
.venv\Scripts\activate````
|
- 260719So_N_04_Chor_D Zum blauen Himmel.wav -> "Zum blauen Himmel (Chor)"
|
||||||
|
- 260719So_N_04_Orchester_D Zum blauen Himmel.wav -> "Zum blauen Himmel (Orchester)"
|
||||||
3. Abhängigkeiten installieren:
|
|
||||||
`````pip install pydub mutagen````
|
---
|
||||||
|
|
||||||
4. FFmpeg herunterladen und den bin-Ordner zur PATH-Umgebungsvariable hinzufügen.
|
## Installation (Endnutzer)
|
||||||
|
|
||||||
# 🚀 Anwendung starten
|
Lade die neueste Version von der Releases-Seite (Gitea) herunter:
|
||||||
1. main.py ausführen:
|
|
||||||
````.venv\Scripts\python.exe main.py````
|
- Windows: fcco-Converter-windows.zip entpacken und fcco-Converter.exe starten.
|
||||||
|
- macOS: fcco-Converter-macos.zip entpacken und fcco-Converter starten.
|
||||||
2. WAV-Dateien auswählen und konvertieren.
|
|
||||||
|
FFmpeg wird automatisch beim ersten Start heruntergeladen. Keine weitere Installation nötig.
|
||||||
|
|
||||||
#📁 Projektstruktur
|
---
|
||||||
|
|
||||||
📦 wav-to-mp3-converter
|
## Updates
|
||||||
├── setup.bat # Batch-Datei zum Starten der Installation
|
|
||||||
├── setup.ps1 # PowerShell-Setupscript
|
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.
|
||||||
├── settings.json # Gespeicherte Einstellungen (automatisch erzeugt)
|
|
||||||
├── main.py # Hauptprogramm mit GUI
|
---
|
||||||
└── README.md # Diese Datei
|
|
||||||
|
## Entwicklung
|
||||||
#ℹ️ Hinweise
|
|
||||||
Nur .wav-Dateien werden akzeptiert.
|
### Voraussetzungen
|
||||||
|
- Python 3.11 oder neuer
|
||||||
Cover muss eine .jpg- oder .jpeg-Datei sein.
|
- Virtuelle Umgebung empfohlen
|
||||||
|
|
||||||
Export erfolgt im .mp3-Format, benannt nach dem Originaldateinamen.
|
### Setup
|
||||||
|
python -m venv .venv
|
||||||
Metadaten können vor jeder Konvertierung überprüft und angepasst werden.
|
source .venv/bin/activate # macOS / Linux
|
||||||
|
# .venv\Scripts\activate # Windows
|
||||||
|
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
### Starten
|
||||||
|
python main.py
|
||||||
|
|
||||||
|
### Build (Standalone-Executable & ZIP-Erstellung)
|
||||||
|
python build.py
|
||||||
|
|
||||||
|
Die fertige App und die Release-ZIP-Datei liegen anschließend im dist/ Verzeichnis.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#!/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
|
||||||
|
sys_name = "macos" if system == "Darwin" else system.lower()
|
||||||
|
zip_name = f"fcco-Converter-{sys_name}"
|
||||||
|
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()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"app_name": "fcco Converter",
|
||||||
|
"app_version": "2.0.2",
|
||||||
|
"repo_owner": "public",
|
||||||
|
"repo_name": "fcco-Converter",
|
||||||
|
"gitea_api_base": "https://git.joelunger.de/api/v1"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# fcco-Converter V2
|
||||||
@@ -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.2",
|
||||||
|
"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)
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
"""
|
||||||
|
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")
|
||||||
|
|
||||||
|
# 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 (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,
|
||||||
|
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"])
|
||||||
|
from .metadata import extract_title_from_filename
|
||||||
|
title = extract_title_from_filename(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)
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""
|
||||||
|
fcco-Converter V2 – Metadaten-Modul
|
||||||
|
|
||||||
|
Hilfsfunktionen für ID3-Tags, Cover-Bilder und Albumname-Generierung.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
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 extract_title_from_filename(filename: str) -> str:
|
||||||
|
"""Extrahiert einen sauberen Titel aus dem komplexen Dateinamen-Format.
|
||||||
|
|
||||||
|
Format-Erwartung: [Prefix]_[Track]_[Type]_[Lang][ Remainder]
|
||||||
|
Beispiele:
|
||||||
|
260719So_N_10_Predigt_D Vorname Nachname -> "Predigt Vorname Nachname"
|
||||||
|
260719So_N_04_Lied_D Zum blauen Himmel -> "Zum blauen Himmel"
|
||||||
|
"""
|
||||||
|
# Regulärer Ausdruck, um die Komponenten zu extrahieren
|
||||||
|
match = re.match(r"^.*?_\d+_(?P<type>[a-zA-Z]+)_(?P<lang>[a-zA-Z]+)(?: (?P<remainder>.*))?$", filename)
|
||||||
|
if not match:
|
||||||
|
return filename
|
||||||
|
|
||||||
|
typ = match.group("type")
|
||||||
|
lang = match.group("lang")
|
||||||
|
remainder = match.group("remainder") or ""
|
||||||
|
|
||||||
|
typ_lower = typ.lower()
|
||||||
|
|
||||||
|
if typ_lower in ["predigt", "vorwort", "gedicht"]:
|
||||||
|
if remainder:
|
||||||
|
return f"{typ} {remainder}"
|
||||||
|
return f"{typ} {lang}"
|
||||||
|
|
||||||
|
elif typ_lower == "lied":
|
||||||
|
if remainder:
|
||||||
|
return remainder
|
||||||
|
return f"{typ} {lang}"
|
||||||
|
|
||||||
|
elif typ_lower in ["chor", "orchester"]:
|
||||||
|
if remainder:
|
||||||
|
return f"{remainder} ({typ})"
|
||||||
|
return f"{typ} {lang}"
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Fallback
|
||||||
|
if remainder:
|
||||||
|
return f"{typ} {remainder}"
|
||||||
|
return f"{typ} {lang}"
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -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)
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 8.7 KiB |
@@ -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)
|
||||||
pass
|
except Exception:
|
||||||
|
|
||||||
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():
|
|
||||||
try:
|
try:
|
||||||
threads = int(threads_entry.get())
|
ctypes.windll.user32.SetProcessDPIAware()
|
||||||
if threads < 0:
|
except Exception:
|
||||||
messagebox.showerror("Fehler", "Threads müssen 0 oder größer sein.")
|
pass
|
||||||
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)
|
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():
|
if __name__ == "__main__":
|
||||||
meta = settings.get("metadata", {}).copy()
|
main()
|
||||||
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()
|
|
||||||
@@ -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
@@ -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
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"export_paths": {
|
|
||||||
"Gottesdienst": "D:\\Tonaufnahmen\\3MP3-192kbit Gottesdienste\\2026",
|
|
||||||
"Hochzeit": "D:\\Tonaufnahmen\\4MP3-192kbit Hochzeiten",
|
|
||||||
"Weissagung": "D:\\Tonaufnahmen\\5Sonstiges\\Weissagungen",
|
|
||||||
"Jugend": "D:\\Tonaufnahmen\\3MP3-192kbit Gottesdienste\\2026"
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"artist": "FECG Rottweil-Neukirch",
|
|
||||||
"cover": "D:\\Portable Software\\fcco_Converter\\Cover\\cover_fecg_rottweil.jpg"
|
|
||||||
},
|
|
||||||
"last_export_dir": "D:\\Tonaufnahmen\\3MP3-192kbit Gottesdienste\\2026\\260406Mo Ostermontag",
|
|
||||||
"threads": 0
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user