Compare commits
2
Commits
6dece1410c
...
v2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,95 @@
|
||||
# 🎵 WAV zu MP3 Konverter
|
||||
# fcco Converter V2
|
||||
|
||||
Ein einfaches, benutzerfreundliches Python-Tool mit grafischer Oberfläche (Tkinter), das WAV-Dateien in MP3-Dateien konvertiert. Es unterstützt automatische Metadaten-Erstellung (Künstler, Album, Titel, Tracknummer) und fügt sogar ein Cover-Bild ein. Alle Dateien werden automatisch alphabetisch sortiert.
|
||||
WAV-zu-MP3-Konverter mit grafischer Benutzeroberfläche. Unterstützt automatische Metadaten-Erstellung (Künstler, Album, Titel, Tracknummer), Cover-Bilder und alphabetische Sortierung. Läuft auf **Windows** und **macOS**.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Features
|
||||
## Features
|
||||
|
||||
- 📂 Mehrere WAV-Dateien auswählen
|
||||
- 📝 Standard-Metadaten definieren (Künstler, Album, Cover)
|
||||
- 🧠 Vorschau & Bearbeitung der Metadaten vor der Konvertierung
|
||||
- 🔄 Automatische Sortierung nach Dateinamen
|
||||
- 🖼️ Cover-Bild einfügen
|
||||
- 💾 Einstellungen werden gespeichert (Metadaten & letzter Export-Ordner)
|
||||
- 📊 Fortschrittsbalken während der Konvertierung
|
||||
- Mehrere WAV-Dateien oder ganze Ordner (mit Unterordnern) auswählen
|
||||
- Metadaten definieren und bearbeiten (Künstler, Album, Jahr, Cover)
|
||||
- Automatische Sortierung nach Dateinamen
|
||||
- Cover-Bild einfügen (JPG/PNG)
|
||||
- Einstellungen werden dauerhaft gespeichert
|
||||
- Fortschrittsanzeige pro Datei und gesamt
|
||||
- Automatisches Update direkt aus der App
|
||||
- Moderne, dunkle Benutzeroberfläche (CustomTkinter)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Setup
|
||||
## Installation (Endnutzer)
|
||||
|
||||
# Automatisches Setup starten
|
||||
Windows-Nutzer können einfach ````setup.bat````` ausführen – es installiert:
|
||||
- Python (falls nicht vorhanden)
|
||||
- Virtuelle Umgebung
|
||||
- Alle benötigten Python-Pakete (pydub, mutagen)
|
||||
- FFmpeg (wird automatisch heruntergeladen & eingebunden)
|
||||
Lade die neueste Version von der [Releases-Seite](https://git.joelunger.de/public/fcco-Converter/releases) herunter:
|
||||
|
||||
# Alternativ: Manuell installieren
|
||||
1. Stelle sicher, dass Python 3.11 oder neuer installiert ist.
|
||||
2. Virtuelle Umgebung erstellen und aktivieren:
|
||||
````python -m venv .venv
|
||||
.venv\Scripts\activate````
|
||||
- **Windows**: `fcco-Converter-windows.zip` entpacken und `fcco-Converter.exe` starten.
|
||||
- **macOS**: `fcco-Converter-macos.zip` entpacken und `fcco-Converter` starten.
|
||||
|
||||
3. Abhängigkeiten installieren:
|
||||
`````pip install pydub mutagen````
|
||||
FFmpeg wird automatisch beim ersten Start heruntergeladen. Keine weitere Installation nötig.
|
||||
|
||||
4. FFmpeg herunterladen und den bin-Ordner zur PATH-Umgebungsvariable hinzufügen.
|
||||
---
|
||||
|
||||
# 🚀 Anwendung starten
|
||||
1. main.py ausführen:
|
||||
````.venv\Scripts\python.exe main.py````
|
||||
## Updates
|
||||
|
||||
2. WAV-Dateien auswählen und konvertieren.
|
||||
In der App auf **"Update prüfen"** klicken. Wenn eine neue Version verfügbar ist, wird sie direkt heruntergeladen und installiert. Danach muss die App einmal neu gestartet werden.
|
||||
|
||||
---
|
||||
|
||||
#📁 Projektstruktur
|
||||
## Entwicklung
|
||||
|
||||
📦 wav-to-mp3-converter
|
||||
├── setup.bat # Batch-Datei zum Starten der Installation
|
||||
├── setup.ps1 # PowerShell-Setupscript
|
||||
├── settings.json # Gespeicherte Einstellungen (automatisch erzeugt)
|
||||
├── main.py # Hauptprogramm mit GUI
|
||||
└── README.md # Diese Datei
|
||||
### Voraussetzungen
|
||||
|
||||
#ℹ️ Hinweise
|
||||
Nur .wav-Dateien werden akzeptiert.
|
||||
- Python 3.11 oder neuer
|
||||
- Virtuelle Umgebung empfohlen
|
||||
|
||||
Cover muss eine .jpg- oder .jpeg-Datei sein.
|
||||
### Setup
|
||||
|
||||
Export erfolgt im .mp3-Format, benannt nach dem Originaldateinamen.
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # macOS / Linux
|
||||
# .venv\Scripts\activate # Windows
|
||||
|
||||
Metadaten können vor jeder Konvertierung überprüft und angepasst werden.
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Starten
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Build (Standalone-Executable)
|
||||
|
||||
```bash
|
||||
python build.py
|
||||
```
|
||||
|
||||
Die fertige Datei liegt anschließend im `dist/`-Verzeichnis.
|
||||
|
||||
---
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
fcco-Converter/
|
||||
├── main.py # Einstiegspunkt
|
||||
├── build.py # Build-Skript (PyInstaller)
|
||||
├── requirements.txt # Abhängigkeiten
|
||||
├── icon.ico # Programm-Icon
|
||||
├── fcco_converter/
|
||||
│ ├── __init__.py
|
||||
│ ├── config.py # Einstellungen & Konfiguration
|
||||
│ ├── converter.py # FFmpeg-Konvertierung
|
||||
│ ├── metadata.py # ID3-Tags & Cover
|
||||
│ ├── updater.py # Auto-Update von Gitea Releases
|
||||
│ └── gui.py # CustomTkinter GUI
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Nur `.wav`-Dateien werden als Eingabe akzeptiert.
|
||||
- Cover-Bilder müssen `.jpg`, `.jpeg` oder `.png` sein.
|
||||
- Export erfolgt im `.mp3`-Format (192 kbit/s).
|
||||
- Einstellungen werden in `~/.config/fcco-Converter/` (Linux), `~/Library/Application Support/fcco-Converter/` (macOS) bzw. `%APPDATA%/fcco-Converter/` (Windows) gespeichert.
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
fcco-Converter V2 – Build-Skript
|
||||
|
||||
Erstellt plattformabhängige Standalone-Executables mit PyInstaller.
|
||||
Aufruf: python build.py
|
||||
"""
|
||||
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def build():
|
||||
system = platform.system()
|
||||
project_dir = Path(__file__).parent
|
||||
|
||||
# Basis-Argumente für PyInstaller
|
||||
args = [
|
||||
sys.executable,
|
||||
"-m", "PyInstaller",
|
||||
"--onefile",
|
||||
"--windowed",
|
||||
"--name", "fcco-Converter",
|
||||
"--clean",
|
||||
]
|
||||
|
||||
# Icon hinzufügen (wenn vorhanden)
|
||||
icon_path = project_dir / "icon.ico"
|
||||
if icon_path.exists():
|
||||
if system == "Windows":
|
||||
args.extend(["--icon", str(icon_path)])
|
||||
elif system == "Darwin":
|
||||
# macOS braucht .icns – .ico funktioniert trotzdem als Fallback
|
||||
args.extend(["--icon", str(icon_path)])
|
||||
|
||||
# Versteckte Imports (die PyInstaller evtl. nicht automatisch findet)
|
||||
hidden_imports = [
|
||||
"customtkinter",
|
||||
"mutagen",
|
||||
"mutagen.easyid3",
|
||||
"mutagen.id3",
|
||||
"static_ffmpeg",
|
||||
]
|
||||
for imp in hidden_imports:
|
||||
args.extend(["--hidden-import", imp])
|
||||
|
||||
# CustomTkinter-Daten einbinden
|
||||
try:
|
||||
import customtkinter
|
||||
ctk_path = Path(customtkinter.__file__).parent
|
||||
if system == "Windows":
|
||||
args.extend(["--add-data", f"{ctk_path};customtkinter"])
|
||||
else:
|
||||
args.extend(["--add-data", f"{ctk_path}:customtkinter"])
|
||||
except ImportError:
|
||||
print("WARNUNG: customtkinter nicht installiert. Bitte 'pip install -r requirements.txt' ausführen.")
|
||||
|
||||
# Hauptdatei
|
||||
args.append(str(project_dir / "main.py"))
|
||||
|
||||
print(f"Build für {system}...")
|
||||
print(f"Befehl: {' '.join(args)}")
|
||||
print()
|
||||
|
||||
result = subprocess.run(args, cwd=str(project_dir))
|
||||
if result.returncode == 0:
|
||||
print()
|
||||
print(f"Build erfolgreich! Ausgabe in: {project_dir / 'dist'}")
|
||||
else:
|
||||
print()
|
||||
print(f"Build fehlgeschlagen (Code {result.returncode}).")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build()
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"app_name": "fcco Converter",
|
||||
"app_version": "2.0.0",
|
||||
"repo_owner": "public",
|
||||
"repo_name": "fcco-Converter",
|
||||
"gitea_api_base": "https://git.joelunger.de/api/v1"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# fcco-Converter V2
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
fcco-Converter V2 – Konfigurationsmodul
|
||||
|
||||
Lädt und speichert Anwendungseinstellungen plattformübergreifend.
|
||||
Verwendet pathlib für alle Pfadoperationen.
|
||||
"""
|
||||
|
||||
import json
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ---- Globale App-Informationen (aus config.json) ----
|
||||
APP_INFO = {
|
||||
"app_name": "fcco Converter",
|
||||
"app_version": "2.0.0",
|
||||
"repo_owner": "public",
|
||||
"repo_name": "fcco-Converter",
|
||||
"gitea_api_base": "https://git.joelunger.de/api/v1"
|
||||
}
|
||||
|
||||
def _load_app_info():
|
||||
try:
|
||||
# Finde config.json relativ zum Skript oder im PyInstaller Bundle
|
||||
if getattr(sys, "frozen", False):
|
||||
base_dir = Path(sys.executable).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,299 @@
|
||||
"""
|
||||
fcco-Converter V2 – Konvertierungsmodul
|
||||
|
||||
Enthält die FFmpeg-basierte Audio-Konvertierung mit static-ffmpeg.
|
||||
Unterstützt erweiterte Einstellungen (Bitrate, Sample-Rate, Kanäle, VBR/CBR).
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Callable
|
||||
|
||||
import static_ffmpeg
|
||||
|
||||
from .metadata import apply_id3_tags
|
||||
|
||||
|
||||
def _ensure_ffmpeg() -> tuple[str, str]:
|
||||
"""Stellt sicher, dass ffmpeg und ffprobe verfügbar sind.
|
||||
|
||||
Returns:
|
||||
(ffmpeg_path, ffprobe_path)
|
||||
"""
|
||||
ffmpeg_path, ffprobe_path = static_ffmpeg.run.get_or_fetch_platform_executables_else_raise()
|
||||
return ffmpeg_path, ffprobe_path
|
||||
|
||||
|
||||
def get_duration(file_path: Path, ffprobe_path: str) -> float:
|
||||
"""Ermittelt die Dauer einer Audiodatei in Sekunden."""
|
||||
cmd = [
|
||||
ffprobe_path,
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
str(file_path),
|
||||
]
|
||||
try:
|
||||
result = subprocess.check_output(
|
||||
cmd, universal_newlines=True, stderr=subprocess.DEVNULL
|
||||
).strip()
|
||||
return float(result)
|
||||
except (subprocess.CalledProcessError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _build_ffmpeg_cmd(
|
||||
ffmpeg_path: str,
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
audio_settings: dict,
|
||||
threads: int = 0,
|
||||
) -> list[str]:
|
||||
"""Baut den ffmpeg-Befehl basierend auf den Audio-Einstellungen.
|
||||
|
||||
Args:
|
||||
ffmpeg_path: Pfad zur ffmpeg-Binary.
|
||||
input_path: Quell-Audiodatei.
|
||||
output_path: Ziel-Ausgabedatei.
|
||||
audio_settings: Dict mit Audio-Konfiguration.
|
||||
threads: Thread-Anzahl (0 = auto).
|
||||
|
||||
Returns:
|
||||
Liste der Befehlsargumente.
|
||||
"""
|
||||
output_format = audio_settings.get("output_format", "mp3")
|
||||
encoding_mode = audio_settings.get("encoding_mode", "CBR")
|
||||
bitrate = audio_settings.get("bitrate", "192")
|
||||
vbr_quality = audio_settings.get("vbr_quality", "2")
|
||||
sample_rate = audio_settings.get("sample_rate", "Original")
|
||||
channels = audio_settings.get("channels", "Original")
|
||||
overwrite = audio_settings.get("overwrite_existing", True)
|
||||
|
||||
cmd = [ffmpeg_path, "-i", str(input_path)]
|
||||
|
||||
# Threads
|
||||
cmd.extend(["-threads", str(threads) if threads > 0 else "0"])
|
||||
|
||||
# Format-spezifische Codec-Einstellungen
|
||||
if output_format == "mp3":
|
||||
cmd.extend(["-c:a", "libmp3lame"])
|
||||
if encoding_mode == "VBR":
|
||||
cmd.extend(["-q:a", vbr_quality])
|
||||
else: # CBR
|
||||
cmd.extend(["-b:a", f"{bitrate}k"])
|
||||
|
||||
elif output_format == "flac":
|
||||
cmd.extend(["-c:a", "flac"])
|
||||
|
||||
elif output_format == "ogg":
|
||||
cmd.extend(["-c:a", "libvorbis"])
|
||||
if encoding_mode == "VBR":
|
||||
# OGG VBR: Qualität 0–10 (wir mappen 0–9 auf 1–10)
|
||||
q = min(10, max(0, 10 - int(vbr_quality)))
|
||||
cmd.extend(["-q:a", str(q)])
|
||||
else:
|
||||
cmd.extend(["-b:a", f"{bitrate}k"])
|
||||
|
||||
elif output_format == "wav":
|
||||
cmd.extend(["-c:a", "pcm_s16le"])
|
||||
|
||||
# Sample-Rate
|
||||
if sample_rate != "Original":
|
||||
cmd.extend(["-ar", sample_rate])
|
||||
|
||||
# Kanäle
|
||||
if channels == "Mono":
|
||||
cmd.extend(["-ac", "1"])
|
||||
elif channels == "Stereo":
|
||||
cmd.extend(["-ac", "2"])
|
||||
|
||||
# Überschreiben
|
||||
if overwrite:
|
||||
cmd.append("-y")
|
||||
else:
|
||||
cmd.append("-n")
|
||||
|
||||
cmd.append(str(output_path))
|
||||
return cmd
|
||||
|
||||
|
||||
def convert_single_file(
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
ffmpeg_path: str,
|
||||
ffprobe_path: str,
|
||||
audio_settings: dict | None = None,
|
||||
threads: int = 0,
|
||||
progress_callback: Callable[[int], None] | None = None,
|
||||
) -> None:
|
||||
"""Konvertiert eine einzelne Audiodatei.
|
||||
|
||||
Args:
|
||||
input_path: Pfad zur Quelldatei.
|
||||
output_path: Pfad für die Zieldatei.
|
||||
ffmpeg_path: Pfad zur ffmpeg-Binary.
|
||||
ffprobe_path: Pfad zur ffprobe-Binary.
|
||||
audio_settings: Dict mit Audio-Konfiguration (aus config).
|
||||
threads: Anzahl der Threads (0 = auto).
|
||||
progress_callback: Wird mit Prozent (0–100) aufgerufen.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Bei Fehlern in der Konvertierung.
|
||||
"""
|
||||
if audio_settings is None:
|
||||
audio_settings = {"output_format": "mp3", "bitrate": "192", "encoding_mode": "CBR"}
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
duration = get_duration(input_path, ffprobe_path)
|
||||
if duration <= 0:
|
||||
raise RuntimeError(f"Dauer der Datei konnte nicht ermittelt werden: {input_path.name}")
|
||||
|
||||
cmd = _build_ffmpeg_cmd(ffmpeg_path, input_path, output_path, audio_settings, threads)
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
while True:
|
||||
line = process.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
if "time=" in line and progress_callback:
|
||||
try:
|
||||
time_str = line.split("time=")[1].split(" ")[0]
|
||||
h, m, s = map(float, time_str.split(":"))
|
||||
current_time = h * 3600 + m * 60 + s
|
||||
percentage = min(int((current_time / duration) * 100), 100)
|
||||
progress_callback(percentage)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
process.wait()
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg-Fehler (Code {process.returncode}) bei: {input_path.name}")
|
||||
|
||||
|
||||
def convert_file_entry(
|
||||
entry: dict,
|
||||
index: int,
|
||||
export_dir: Path,
|
||||
meta: dict,
|
||||
use_subdirs: bool,
|
||||
ffmpeg_path: str,
|
||||
ffprobe_path: str,
|
||||
audio_settings: dict | None = None,
|
||||
threads: int = 0,
|
||||
progress_callback: Callable[[int, int, str], None] | None = None,
|
||||
) -> tuple[int, str, str | None]:
|
||||
"""Konvertiert eine Datei und schreibt die Metadaten.
|
||||
|
||||
Returns:
|
||||
(index, filename, error_or_None)
|
||||
"""
|
||||
if audio_settings is None:
|
||||
audio_settings = {"output_format": "mp3", "bitrate": "192", "encoding_mode": "CBR"}
|
||||
|
||||
file_path = Path(entry["full_path"])
|
||||
title = file_path.stem
|
||||
output_format = audio_settings.get("output_format", "mp3")
|
||||
|
||||
try:
|
||||
if use_subdirs and entry["rel_dir"]:
|
||||
output_dir = export_dir / entry["rel_dir"]
|
||||
else:
|
||||
output_dir = export_dir
|
||||
|
||||
output_file = output_dir / f"{title}.{output_format}"
|
||||
|
||||
def _on_progress(pct: int):
|
||||
if progress_callback:
|
||||
progress_callback(index, pct, title)
|
||||
|
||||
convert_single_file(
|
||||
input_path=file_path,
|
||||
output_path=output_file,
|
||||
ffmpeg_path=ffmpeg_path,
|
||||
ffprobe_path=ffprobe_path,
|
||||
audio_settings=audio_settings,
|
||||
threads=threads,
|
||||
progress_callback=_on_progress,
|
||||
)
|
||||
|
||||
# ID3-Metadaten nur für MP3 schreiben
|
||||
if output_format == "mp3":
|
||||
cover_path = Path(meta["cover"]) if meta.get("cover") else None
|
||||
apply_id3_tags(
|
||||
mp3_path=output_file,
|
||||
title=title,
|
||||
artist=meta.get("artist", ""),
|
||||
album=meta.get("album", ""),
|
||||
track_number=index + 1,
|
||||
year=meta.get("year", ""),
|
||||
cover_path=cover_path,
|
||||
)
|
||||
|
||||
return (index, f"{title}.{output_format}", None)
|
||||
|
||||
except Exception as e:
|
||||
return (index, file_path.name, str(e))
|
||||
|
||||
|
||||
def run_batch_conversion(
|
||||
entries: list[dict],
|
||||
export_dir: Path,
|
||||
meta: dict,
|
||||
use_subdirs: bool,
|
||||
audio_settings: dict | None = None,
|
||||
max_workers: int = 0,
|
||||
progress_callback: Callable[[int, int, str], None] | None = None,
|
||||
completion_callback: Callable[[list[str], list[tuple[str, str]]], None] | None = None,
|
||||
) -> None:
|
||||
"""Führt die Batch-Konvertierung aller Dateien durch."""
|
||||
if audio_settings is None:
|
||||
audio_settings = {"output_format": "mp3", "bitrate": "192", "encoding_mode": "CBR"}
|
||||
|
||||
ffmpeg_path, ffprobe_path = _ensure_ffmpeg()
|
||||
|
||||
if max_workers <= 0:
|
||||
max_workers = os.cpu_count() or 1
|
||||
|
||||
successful_files = []
|
||||
errors = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(
|
||||
convert_file_entry,
|
||||
entry=entry,
|
||||
index=idx,
|
||||
export_dir=export_dir,
|
||||
meta=meta,
|
||||
use_subdirs=use_subdirs,
|
||||
ffmpeg_path=ffmpeg_path,
|
||||
ffprobe_path=ffprobe_path,
|
||||
audio_settings=audio_settings,
|
||||
threads=0,
|
||||
progress_callback=progress_callback,
|
||||
): idx
|
||||
for idx, entry in enumerate(entries)
|
||||
}
|
||||
|
||||
for future in as_completed(futures):
|
||||
index, filename, error = future.result()
|
||||
if error:
|
||||
errors.append((filename, error))
|
||||
if progress_callback:
|
||||
progress_callback(index, 100, filename)
|
||||
else:
|
||||
successful_files.append(filename)
|
||||
if progress_callback:
|
||||
progress_callback(index, 100, filename)
|
||||
|
||||
if completion_callback:
|
||||
completion_callback(successful_files, errors)
|
||||
@@ -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,88 @@
|
||||
"""
|
||||
fcco-Converter V2 – Metadaten-Modul
|
||||
|
||||
Hilfsfunktionen für ID3-Tags, Cover-Bilder und Albumname-Generierung.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from mutagen.easyid3 import EasyID3
|
||||
from mutagen.id3 import ID3, APIC
|
||||
|
||||
from .config import WEEKDAY_SHORT_DE
|
||||
|
||||
|
||||
def generate_album_name(veranstaltung: str = "") -> str:
|
||||
"""Erstellt einen Albumnamen basierend auf dem aktuellen Datum und der Veranstaltung."""
|
||||
date_str = datetime.now().strftime("%d.%m.%Y")
|
||||
if veranstaltung:
|
||||
return f"{date_str} {veranstaltung}"
|
||||
return date_str
|
||||
|
||||
|
||||
def generate_folder_name(date_part: str, veranstaltung: str) -> str:
|
||||
"""Erstellt den Ordnernamen für den Export."""
|
||||
if veranstaltung:
|
||||
return f"{date_part} {veranstaltung}"
|
||||
return date_part
|
||||
|
||||
|
||||
def generate_date_part() -> str:
|
||||
"""Erstellt das Datumskürzel (z.B. '260719Sa') für die aktuelle Zeit."""
|
||||
now = datetime.now()
|
||||
return now.strftime("%y%m%d") + WEEKDAY_SHORT_DE[now.weekday()]
|
||||
|
||||
|
||||
def extract_event_from_folder(file_path: Path) -> tuple[str, str]:
|
||||
"""Versucht, Datum und Veranstaltung aus dem Ordnernamen zu extrahieren.
|
||||
|
||||
Erwartetes Format: 'YYMMDDWt Veranstaltungsname'
|
||||
z.B. '260406Mo Ostermontag'
|
||||
|
||||
Returns:
|
||||
(date_part, veranstaltung) oder ("", "") wenn nicht erkannt.
|
||||
"""
|
||||
folder_name = file_path.parent.name
|
||||
if len(folder_name) >= 8 and folder_name[:6].isdigit() and folder_name[6:8] in WEEKDAY_SHORT_DE:
|
||||
date_part = folder_name[:8]
|
||||
veranstaltung = folder_name[8:].strip() if len(folder_name) > 8 else ""
|
||||
return date_part, veranstaltung
|
||||
return "", ""
|
||||
|
||||
|
||||
def apply_id3_tags(
|
||||
mp3_path: Path,
|
||||
title: str,
|
||||
artist: str,
|
||||
album: str,
|
||||
track_number: int,
|
||||
year: str,
|
||||
cover_path: Path | None = None,
|
||||
) -> None:
|
||||
"""Schreibt ID3-Metadaten und optional ein Cover in die MP3-Datei."""
|
||||
audiofile = EasyID3(str(mp3_path))
|
||||
audiofile["title"] = title
|
||||
audiofile["artist"] = artist or "Unbekannt"
|
||||
audiofile["album"] = album or "Unbekanntes Album"
|
||||
audiofile["tracknumber"] = str(track_number)
|
||||
audiofile["date"] = year
|
||||
audiofile.save()
|
||||
|
||||
if cover_path and cover_path.exists():
|
||||
audio_id3 = ID3(str(mp3_path))
|
||||
mime = "image/jpeg"
|
||||
suffix = cover_path.suffix.lower()
|
||||
if suffix == ".png":
|
||||
mime = "image/png"
|
||||
with open(cover_path, "rb") as img:
|
||||
audio_id3.add(
|
||||
APIC(
|
||||
encoding=3,
|
||||
mime=mime,
|
||||
type=3,
|
||||
desc="Cover",
|
||||
data=img.read(),
|
||||
)
|
||||
)
|
||||
audio_id3.save()
|
||||
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
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 check_for_updates() -> dict | None:
|
||||
"""Prüft, ob ein neues Release verfügbar ist.
|
||||
|
||||
Returns:
|
||||
Dict mit Release-Infos (tag_name, name, body, assets) oder None.
|
||||
"""
|
||||
try:
|
||||
releases = _fetch_json(RELEASES_URL)
|
||||
if not releases:
|
||||
return None
|
||||
|
||||
# Neuestes (nicht als Draft/Prerelease markiertes) Release
|
||||
for release in releases:
|
||||
if release.get("draft") or release.get("prerelease"):
|
||||
continue
|
||||
|
||||
tag = release.get("tag_name", "").lstrip("v")
|
||||
if tag and tag != CURRENT_VERSION:
|
||||
# Einfacher Versionsvergleich
|
||||
try:
|
||||
remote_parts = [int(x) for x in tag.split(".")]
|
||||
local_parts = [int(x) for x in CURRENT_VERSION.split(".")]
|
||||
if remote_parts > local_parts:
|
||||
return release
|
||||
except ValueError:
|
||||
continue
|
||||
break # Nur das neueste Release prüfen
|
||||
|
||||
return None
|
||||
except (URLError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _get_platform_asset_name() -> str:
|
||||
"""Ermittelt den erwarteten Asset-Dateinamen für das aktuelle Betriebssystem."""
|
||||
system = platform.system().lower()
|
||||
if system == "windows":
|
||||
return "fcco-Converter-windows.zip"
|
||||
elif system == "darwin":
|
||||
return "fcco-Converter-macos.zip"
|
||||
else:
|
||||
return "fcco-Converter-linux.zip"
|
||||
|
||||
|
||||
def _find_download_url(release: dict) -> str | None:
|
||||
"""Findet die Download-URL für das passende Plattform-Asset."""
|
||||
expected_name = _get_platform_asset_name()
|
||||
for asset in release.get("assets", []):
|
||||
if asset.get("name", "").lower() == expected_name.lower():
|
||||
return asset.get("browser_download_url")
|
||||
return None
|
||||
|
||||
|
||||
def download_and_install_update(
|
||||
release: dict,
|
||||
progress_callback: Callable[[int, str], None] | None = None,
|
||||
) -> bool:
|
||||
"""Lädt das neueste Release herunter und installiert es.
|
||||
|
||||
Für Standalone-Builds (PyInstaller): Ersetzt die aktuelle Executable.
|
||||
Für Skript-Modus: Entpackt in das Projektverzeichnis.
|
||||
|
||||
Args:
|
||||
release: Release-Dict von der Gitea-API.
|
||||
progress_callback: Callback(percentage, status_message).
|
||||
|
||||
Returns:
|
||||
True bei Erfolg, False bei Fehler.
|
||||
"""
|
||||
download_url = _find_download_url(release)
|
||||
if not download_url:
|
||||
if progress_callback:
|
||||
progress_callback(0, "Kein passendes Asset für dieses Betriebssystem gefunden.")
|
||||
return False
|
||||
|
||||
try:
|
||||
if progress_callback:
|
||||
progress_callback(10, "Download wird gestartet...")
|
||||
|
||||
# Download in temporäres Verzeichnis
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix="fcco_update_"))
|
||||
zip_path = tmp_dir / "update.zip"
|
||||
|
||||
req = Request(download_url)
|
||||
with urlopen(req, timeout=120) as resp:
|
||||
total_size = int(resp.headers.get("Content-Length", 0))
|
||||
downloaded = 0
|
||||
chunk_size = 8192
|
||||
|
||||
with open(zip_path, "wb") as f:
|
||||
while True:
|
||||
chunk = resp.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if total_size > 0 and progress_callback:
|
||||
pct = min(int((downloaded / total_size) * 70) + 10, 80)
|
||||
progress_callback(pct, f"Heruntergeladen: {downloaded // 1024} KB")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(85, "Entpacken...")
|
||||
|
||||
# Entpacken
|
||||
extract_dir = tmp_dir / "extracted"
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
zf.extractall(extract_dir)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(90, "Installation...")
|
||||
|
||||
# Bestimme, wo das Programm läuft
|
||||
if getattr(sys, "frozen", False):
|
||||
# PyInstaller-Bundle: Ersetze die aktuelle Executable
|
||||
current_exe = Path(sys.executable)
|
||||
_install_frozen(extract_dir, current_exe)
|
||||
else:
|
||||
# Skript-Modus: Ersetze die Projektdateien
|
||||
project_dir = Path(__file__).parent.parent
|
||||
_install_script(extract_dir, project_dir)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(100, "Update abgeschlossen! Bitte Programm neu starten.")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
if progress_callback:
|
||||
progress_callback(0, f"Fehler: {e}")
|
||||
return False
|
||||
finally:
|
||||
# Aufräumen (best-effort)
|
||||
try:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _install_frozen(extract_dir: Path, current_exe: Path) -> None:
|
||||
"""Installiert ein Update für eine PyInstaller-gepackte Anwendung."""
|
||||
# Suche die neue Executable im entpackten Verzeichnis
|
||||
system = platform.system().lower()
|
||||
if system == "windows":
|
||||
new_exe_name = "fcco-Converter.exe"
|
||||
elif system == "darwin":
|
||||
new_exe_name = "fcco-Converter"
|
||||
else:
|
||||
new_exe_name = "fcco-Converter"
|
||||
|
||||
new_exe = None
|
||||
for item in extract_dir.rglob(new_exe_name):
|
||||
new_exe = item
|
||||
break
|
||||
|
||||
if not new_exe:
|
||||
raise FileNotFoundError(f"Neue Programmdatei '{new_exe_name}' nicht im Update gefunden.")
|
||||
|
||||
# Auf Windows: Aktuelle Exe umbenennen, dann neue kopieren
|
||||
backup = current_exe.with_suffix(".old")
|
||||
if backup.exists():
|
||||
backup.unlink()
|
||||
|
||||
if system == "windows":
|
||||
current_exe.rename(backup)
|
||||
shutil.copy2(new_exe, current_exe)
|
||||
else:
|
||||
# macOS/Linux: Direkt ersetzen
|
||||
shutil.copy2(new_exe, current_exe)
|
||||
os.chmod(current_exe, 0o755)
|
||||
|
||||
|
||||
def _install_script(extract_dir: Path, project_dir: Path) -> None:
|
||||
"""Installiert ein Update im Skript-Modus (Quelldateien ersetzen)."""
|
||||
# Alle .py-Dateien aus dem entpackten Verzeichnis kopieren
|
||||
for item in extract_dir.rglob("*"):
|
||||
if item.is_file():
|
||||
rel = item.relative_to(extract_dir)
|
||||
# Überspringe versteckte Dateien und Verzeichnisse
|
||||
if any(part.startswith(".") for part in rel.parts):
|
||||
continue
|
||||
dest = project_dir / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(item, dest)
|
||||
@@ -1,450 +1,30 @@
|
||||
import os
|
||||
import json
|
||||
import ctypes
|
||||
import threading
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from tkinter import filedialog, messagebox, simpledialog, Toplevel, Button
|
||||
from tkinter import *
|
||||
from tkinter.ttk import Progressbar
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
fcco-Converter V2 – Einstiegspunkt
|
||||
|
||||
try:
|
||||
from mutagen.easyid3 import EasyID3
|
||||
from mutagen.id3 import ID3, APIC
|
||||
except ImportError:
|
||||
messagebox.showerror("Fehler", "Die Bibliothek 'mutagen' ist nicht installiert. Bitte installieren Sie sie mit 'pip install mutagen'.")
|
||||
exit()
|
||||
Startet die grafische Benutzeroberfläche.
|
||||
"""
|
||||
|
||||
SETTINGS_FILE = "settings.json"
|
||||
HISTORY_FILE = "history.json"
|
||||
import sys
|
||||
import platform
|
||||
|
||||
WEEKDAY_SHORT_DE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||
|
||||
DEFAULT_EXPORT_PATHS = {
|
||||
"Gottesdienst": "gottesdienste",
|
||||
"Hochzeit": "hochzeiten",
|
||||
"Weissagung": "weissagungen",
|
||||
"Jugend": "jugend"
|
||||
}
|
||||
|
||||
def load_settings():
|
||||
if os.path.exists(SETTINGS_FILE):
|
||||
with open(SETTINGS_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
return {
|
||||
"export_paths": DEFAULT_EXPORT_PATHS.copy(),
|
||||
"threads": 0 # 0 = auto, use all available cores
|
||||
}
|
||||
|
||||
def save_settings(settings):
|
||||
with open(SETTINGS_FILE, "w") as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
|
||||
def save_export_history(entry):
|
||||
history = []
|
||||
if os.path.exists(HISTORY_FILE):
|
||||
with open(HISTORY_FILE, "r") as f:
|
||||
history = json.load(f)
|
||||
history.append(entry)
|
||||
with open(HISTORY_FILE, "w") as f:
|
||||
json.dump(history, f, indent=2)
|
||||
|
||||
# Fix pixelation on high-DPI displays
|
||||
try:
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(2) # Per-monitor DPI awareness
|
||||
except:
|
||||
# DPI-Awareness auf Windows setzen (vor dem GUI-Import)
|
||||
if platform.system() == "Windows":
|
||||
try:
|
||||
ctypes.windll.user32.SetProcessDPIAware() # Fallback for older Windows
|
||||
except:
|
||||
import ctypes
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(2)
|
||||
except Exception:
|
||||
try:
|
||||
ctypes.windll.user32.SetProcessDPIAware()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
settings = load_settings()
|
||||
|
||||
root = Tk()
|
||||
root.iconbitmap("icon.ico")
|
||||
root.title("fcco Converter")
|
||||
root.geometry("800x1200")
|
||||
def main():
|
||||
from fcco_converter.gui import App
|
||||
app = App()
|
||||
app.mainloop()
|
||||
|
||||
Label(root, text="Wähle WAV-Dateien oder Ordner aus:").pack(pady=5)
|
||||
file_listbox = Listbox(root, width=60)
|
||||
file_listbox.pack(pady=5)
|
||||
|
||||
file_status_listbox = Listbox(root, width=60)
|
||||
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:
|
||||
threads = int(threads_entry.get())
|
||||
if threads < 0:
|
||||
messagebox.showerror("Fehler", "Threads müssen 0 oder größer sein.")
|
||||
return
|
||||
except ValueError:
|
||||
messagebox.showerror("Fehler", "Bitte geben Sie eine gültige Zahl für Threads ein.")
|
||||
return
|
||||
|
||||
settings["metadata"] = {
|
||||
"artist": artist_entry.get(),
|
||||
"cover": cover_entry.get()
|
||||
}
|
||||
settings["export_paths"] = {k: v.get() for k, v in entries.items()}
|
||||
settings["threads"] = threads
|
||||
save_settings(settings)
|
||||
messagebox.showinfo("Gespeichert", "Einstellungen wurden gespeichert.")
|
||||
win.destroy()
|
||||
|
||||
Button(win, text="Speichern", command=save_all).pack(pady=10)
|
||||
|
||||
Button(root, text="Einstellungen öffnen", command=open_settings_window).pack(pady=5)
|
||||
|
||||
def confirm_metadata_and_type():
|
||||
meta = settings.get("metadata", {}).copy()
|
||||
if file_entries:
|
||||
date_part, veranstaltung = extract_event_from_folder(file_entries[0]['full_path'])
|
||||
if not date_part:
|
||||
date_part = datetime.now().strftime("%y%m%d") + WEEKDAY_SHORT_DE[datetime.now().weekday()]
|
||||
else:
|
||||
date_part = datetime.now().strftime("%y%m%d") + WEEKDAY_SHORT_DE[datetime.now().weekday()]
|
||||
veranstaltung = ""
|
||||
meta["album"] = generate_album_name(veranstaltung)
|
||||
meta["year"] = str(datetime.now().year)
|
||||
|
||||
win = Toplevel(root)
|
||||
win.iconbitmap("icon.ico")
|
||||
win.title("Metadaten und Exportoptionen")
|
||||
win.geometry("650x650")
|
||||
|
||||
Label(win, text="Künstlername:").pack()
|
||||
artist_entry = Entry(win, width=40)
|
||||
artist_entry.pack()
|
||||
artist_entry.insert(0, meta.get("artist", ""))
|
||||
|
||||
Label(win, text="Albumname:").pack()
|
||||
album_entry = Entry(win, width=40)
|
||||
album_entry.pack()
|
||||
album_entry.insert(0, meta.get("album", ""))
|
||||
|
||||
Label(win, text="Jahr:").pack()
|
||||
year_entry = Entry(win, width=40)
|
||||
year_entry.pack()
|
||||
year_entry.insert(0, meta.get("year", ""))
|
||||
|
||||
Label(win, text="Cover-Pfad:").pack()
|
||||
cover_entry = Entry(win, width=40)
|
||||
cover_entry.pack()
|
||||
cover_entry.insert(0, meta.get("cover", ""))
|
||||
|
||||
Label(win, text="Art des Inhalts:").pack(pady=10)
|
||||
content_type = StringVar(value="Gottesdienst")
|
||||
for option in ["Gottesdienst", "Hochzeit", "Weissagung", "Jugend", "Sonstiges"]:
|
||||
Radiobutton(win, text=option, variable=content_type, value=option).pack(anchor=W)
|
||||
|
||||
date_event_frame = Frame(win)
|
||||
date_event_frame.pack(pady=10)
|
||||
|
||||
Label(date_event_frame, text="Datum:").grid(row=0, column=0, sticky="ew")
|
||||
date_entry = Entry(date_event_frame, width=20)
|
||||
date_entry.grid(row=1, column=0, padx=5, pady=5)
|
||||
date_entry.insert(0, date_part)
|
||||
|
||||
Label(date_event_frame, text="Veranstaltung (optional):").grid(row=0, column=1, sticky="ew")
|
||||
event_entry = Entry(date_event_frame, width=20)
|
||||
event_entry.grid(row=1, column=1, padx=5, pady=5)
|
||||
event_entry.insert(0, veranstaltung)
|
||||
|
||||
def update_album_name(*args):
|
||||
event = event_entry.get().strip()
|
||||
album_entry.delete(0, END)
|
||||
album_entry.insert(0, generate_album_name(event))
|
||||
|
||||
event_entry.bind("<KeyRelease>", update_album_name)
|
||||
|
||||
confirmed_data = {}
|
||||
|
||||
def confirm():
|
||||
confirmed_data["artist"] = artist_entry.get()
|
||||
confirmed_data["album"] = album_entry.get()
|
||||
confirmed_data["year"] = year_entry.get()
|
||||
confirmed_data["cover"] = cover_entry.get()
|
||||
confirmed_data["type"] = content_type.get()
|
||||
confirmed_data["event"] = event_entry.get().strip()
|
||||
confirmed_data["date_part"] = date_entry.get().strip()
|
||||
win.destroy()
|
||||
|
||||
Button(win, text="Bestätigen", command=confirm).pack(pady=10)
|
||||
win.grab_set()
|
||||
root.wait_window(win)
|
||||
|
||||
return confirmed_data if confirmed_data else None
|
||||
|
||||
def get_duration(file):
|
||||
cmd = [
|
||||
"ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1", file
|
||||
]
|
||||
try:
|
||||
return float(subprocess.check_output(cmd, universal_newlines=True).strip())
|
||||
except:
|
||||
return 0
|
||||
|
||||
def convert_file(entry, index, export_dir, meta, use_subdirs):
|
||||
file = entry['full_path']
|
||||
rel_dir = entry['rel_dir']
|
||||
try:
|
||||
title = os.path.splitext(os.path.basename(file))[0]
|
||||
if use_subdirs and rel_dir:
|
||||
export_subdir = os.path.join(export_dir, rel_dir)
|
||||
os.makedirs(export_subdir, exist_ok=True)
|
||||
mp3_path = os.path.join(export_subdir, f"{title}.mp3")
|
||||
else:
|
||||
mp3_path = os.path.join(export_dir, f"{title}.mp3")
|
||||
|
||||
duration = get_duration(file)
|
||||
if duration == 0:
|
||||
raise Exception("Could not determine file duration.")
|
||||
|
||||
threads = settings.get("threads", 0)
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-i", file,
|
||||
"-threads", str(threads) if threads > 0 else "0",
|
||||
"-c:a", "libmp3lame",
|
||||
"-b:a", "192k",
|
||||
"-y",
|
||||
mp3_path
|
||||
]
|
||||
|
||||
process = subprocess.Popen(cmd, stderr=subprocess.PIPE, universal_newlines=True)
|
||||
while True:
|
||||
line = process.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
if "time=" in line:
|
||||
time_str = line.split("time=")[1].split(" ")[0]
|
||||
try:
|
||||
h, m, s = map(float, time_str.split(':'))
|
||||
current_time = h * 3600 + m * 60 + s
|
||||
percentage = min(int((current_time / duration) * 100), 100)
|
||||
root.after(0, lambda idx=index, pct=percentage, ttl=title: update_progress(idx, pct, ttl, "Konvertiert..."))
|
||||
except:
|
||||
pass
|
||||
|
||||
process.wait()
|
||||
if process.returncode != 0:
|
||||
raise subprocess.CalledProcessError(process.returncode, cmd)
|
||||
|
||||
audiofile = EasyID3(mp3_path)
|
||||
audiofile["title"] = title
|
||||
audiofile["artist"] = meta["artist"] or "Unbekannt"
|
||||
audiofile["album"] = meta["album"] or "Unbekanntes Album"
|
||||
audiofile["tracknumber"] = str(index + 1)
|
||||
audiofile["date"] = meta.get("year", "")
|
||||
audiofile.save()
|
||||
|
||||
audiofile = ID3(mp3_path)
|
||||
with open(meta["cover"], "rb") as img:
|
||||
audiofile.add(APIC(
|
||||
encoding=3,
|
||||
mime='image/jpeg',
|
||||
type=3,
|
||||
desc=u'Cover',
|
||||
data=img.read()
|
||||
))
|
||||
audiofile.save()
|
||||
|
||||
return (index, f"{title}.mp3", None)
|
||||
except subprocess.CalledProcessError as e:
|
||||
return (index, os.path.basename(file), f"FFmpeg Error: {str(e)}")
|
||||
except Exception as e:
|
||||
return (index, os.path.basename(file), str(e))
|
||||
|
||||
def update_progress(index, percentage, filename, status):
|
||||
file_status_listbox.delete(index)
|
||||
file_status_listbox.insert(index, f"{percentage}% – {filename} – {status}")
|
||||
progress_bars[index]["value"] = percentage
|
||||
root.update_idletasks()
|
||||
|
||||
def convert():
|
||||
global is_converting, conversion_running
|
||||
if not file_entries or conversion_running:
|
||||
if not file_entries:
|
||||
messagebox.showerror("Fehler", "Keine Dateien ausgewählt.")
|
||||
return
|
||||
|
||||
conversion_running = True
|
||||
result = confirm_metadata_and_type()
|
||||
if not result:
|
||||
conversion_running = False
|
||||
return
|
||||
|
||||
meta = result
|
||||
content_type = meta["type"]
|
||||
veranstaltung = meta.get("event", "").strip()
|
||||
date_part = meta.get("date_part", datetime.now().strftime("%y%m%d") + WEEKDAY_SHORT_DE[datetime.now().weekday()])
|
||||
|
||||
# Use subdirectories if a folder was selected
|
||||
use_subdirs = (base_folder is not None)
|
||||
|
||||
if content_type in settings.get("export_paths", {}) and content_type != "Sonstiges":
|
||||
base_dir = settings["export_paths"][content_type]
|
||||
os.makedirs(base_dir, exist_ok=True)
|
||||
folder_name = generate_folder_name(date_part, veranstaltung)
|
||||
export_dir = os.path.join(base_dir, folder_name)
|
||||
os.makedirs(export_dir, exist_ok=True)
|
||||
else:
|
||||
export_dir = filedialog.askdirectory(title="Export-Ordner wählen")
|
||||
if not export_dir:
|
||||
conversion_running = False
|
||||
return
|
||||
|
||||
settings["last_export_dir"] = export_dir
|
||||
save_settings(settings)
|
||||
|
||||
sorted_entries = sorted(file_entries, key=lambda x: os.path.basename(x['full_path']).lower())
|
||||
total = len(sorted_entries)
|
||||
file_status_listbox.delete(0, END)
|
||||
for e in sorted_entries:
|
||||
file_status_listbox.insert(END, f"0% – {os.path.basename(e['full_path'])} – Wartet...")
|
||||
|
||||
def run_conversion():
|
||||
global is_converting, conversion_running
|
||||
is_converting = True
|
||||
successful_files = []
|
||||
max_workers = settings.get("threads", 0) or os.cpu_count() or 1
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_file = {executor.submit(convert_file, entry, idx, export_dir, meta, use_subdirs): idx for idx, entry in enumerate(sorted_entries)}
|
||||
for future in as_completed(future_to_file):
|
||||
index = future_to_file[future]
|
||||
result = future.result()
|
||||
index, filename, error = result
|
||||
if error:
|
||||
update_progress(index, 100, filename, f"Fehler: {error}")
|
||||
else:
|
||||
update_progress(index, 100, filename, "Fertig!")
|
||||
successful_files.append(filename)
|
||||
|
||||
save_export_history({
|
||||
"datum": datetime.now().strftime("%d.%m.%Y %H:%M"),
|
||||
"ordner": export_dir,
|
||||
"dateien": successful_files
|
||||
})
|
||||
|
||||
# Custom completion popup with "OK" and "Programm schließen"
|
||||
win = Toplevel(root)
|
||||
win.iconbitmap("icon.ico")
|
||||
win.title("Konvertierung abgeschlossen")
|
||||
win.geometry("300x150")
|
||||
Label(win, text="Konvertierung abgeschlossen!", font=("Arial", 12)).pack(pady=20)
|
||||
Button(win, text="OK", command=win.destroy, width=10).pack(side=LEFT, padx=10, pady=10)
|
||||
Button(win, text="Programm schließen", command=root.destroy, width=15).pack(side=RIGHT, padx=10, pady=10)
|
||||
win.grab_set()
|
||||
|
||||
is_converting = False
|
||||
conversion_running = False
|
||||
|
||||
threading.Thread(target=run_conversion).start()
|
||||
|
||||
def on_closing():
|
||||
global is_converting
|
||||
if is_converting:
|
||||
if messagebox.askyesno("Bestätigung", "Konvertierung läuft. Möchten Sie das Programm wirklich beenden?"):
|
||||
root.destroy()
|
||||
else:
|
||||
root.destroy()
|
||||
|
||||
Button(root, text="Konvertieren", command=convert).pack(pady=10)
|
||||
|
||||
root.protocol("WM_DELETE_WINDOW", on_closing)
|
||||
|
||||
root.mainloop()
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
mutagen
|
||||
tkinter
|
||||
datetime
|
||||
customtkinter>=5.2.0
|
||||
mutagen>=1.47.0
|
||||
static-ffmpeg>=2.7
|
||||
requests>=2.31.0
|
||||
Pillow>=10.0.0
|
||||
pyinstaller>=6.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