2 Commits
22 changed files with 2344 additions and 714 deletions
Vendored
BIN
View File
Binary file not shown.
-67
View File
@@ -1,67 +0,0 @@
name: Build Windows EXE & Release
on:
push:
tags:
- 'v*' # Wird ausgelöst wenn du z.B. "v1.0.0" pushst
jobs:
build-windows:
runs-on: windows-latest # Benötigt einen Windows-Runner!
steps:
- name: Code auschecken
uses: actions/checkout@v4
- name: Python 3.11 installieren
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: FFmpeg installieren (via Chocolatey)
run: |
choco install ffmpeg -y
shell: powershell
- name: Python-Abhängigkeiten installieren
run: |
pip install --upgrade pip
pip install mutagen pydub pyinstaller
- name: .exe bauen mit PyInstaller
run: |
pyinstaller `
--onefile `
--windowed `
--icon=icon.ico `
--name="fcco-Converter" `
--add-data="icon.ico;." `
main.py
shell: powershell
- name: Release-Archiv erstellen (ZIP)
run: |
Compress-Archive -Path dist\fcco-Converter.exe -DestinationPath dist\fcco-Converter-${{ gitea.ref_name }}-windows.zip
shell: powershell
- name: Gitea Release erstellen & EXE hochladen
uses: https://gitea.com/actions/gitea-release-action@main
with:
token: ${{ secrets.RELEASE_TOKEN }}
tag_name: ${{ gitea.ref_name }}
release_name: "fcco-Converter ${{ gitea.ref_name }}"
body: |
## fcco-Converter ${{ gitea.ref_name }}
### Download
- **fcco-Converter-${{ gitea.ref_name }}-windows.zip** Windows Standalone (.exe)
### Voraussetzungen
- FFmpeg muss auf dem System installiert sein (oder im PATH liegen)
- Beim ersten Start: `settings.json` wird automatisch erstellt
### Installation
1. ZIP entpacken
2. `fcco-Converter.exe` starten
files: |
dist/fcco-Converter-${{ gitea.ref_name }}-windows.zip
+21 -8
View File
@@ -1,13 +1,26 @@
# OS
.DS_Store
Cover/*
App
.venv
Thumbs.db
# PyInstaller Build-Artefakte
build/
dist/
# Python
__pycache__/
*.pyc
*.py[cod]
*.pyo
*.egg-info/
dist/
build/
*.spec
# Laufzeit-Dateien (werden lokal generiert)
# Virtual Environment
.venv/
venv/
env/
# IDE
.vscode/
.idea/
# App Data
Cover/*
settings.json
history.json
+73 -48
View File
@@ -1,70 +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)
---
## 🛠️ Installation
## Installation (Endnutzer)
Damit das Programm reibungslos funktioniert, gibt es Installationsskripte, die automatisch alle benötigten Komponenten (`Python`, virtuelle Umgebung, Abhängigkeiten sowie `FFmpeg`) für dein System herunterladen und einrichten.
Lade die neueste Version von der [Releases-Seite](https://git.joelunger.de/public/fcco-Converter/releases) herunter:
### 🍏 macOS
1. Öffne ein Terminal und navigiere in das Verzeichnis deines Projekts, oder speziell in den `install` Ordner.
2. Mache das macOS Installationsskript ausführbar und führe es aus:
```bash
cd install
chmod +x install_macos.sh
./install_macos.sh
```
*Dieses Skript nutzt Homebrew, um `python` und `ffmpeg` zu installieren, erstellt eine isolierte virtuelle Umgebung und lädt die benötigten Python-Bibliotheken herunter.*
- **Windows**: `fcco-Converter-windows.zip` entpacken und `fcco-Converter.exe` starten.
- **macOS**: `fcco-Converter-macos.zip` entpacken und `fcco-Converter` starten.
### 🪟 Windows
1. Wechsle in den Unterordner **`install`**.
2. Mach einen **Rechtsklick** auf die Datei **`install_windows.ps1`** und wähle **"Mit PowerShell ausführen"**.
*Dieses Skript prüft und installiert `python` und `ffmpeg` mithilfe von WinGet, erstellt dann eine virtuelle `.venv` und installiert die nötigen Pakete.*
FFmpeg wird automatisch beim ersten Start heruntergeladen. Keine weitere Installation nötig.
---
## 🚀 Anwendung starten
## Updates
Nach der erfolgreichen Installation, kannst du den Konverter immer kinderleicht starten:
- **Auf macOS:**
Führe **`start.sh`** aus dem Hauptverzeichnis aus. Entweder per Terminal (`./start.sh`) oder per Rechtsklick -> "Öffnen mit" -> "Terminal".
- **Auf Windows:**
Einfach einen Doppelklick auf die Datei **`start.bat`** im Hauptverzeichnis ausführen.
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
📦 fcco-Converter
├── main.py # Hauptprogramm mit GUI
├── start.bat # Startskript für Windows
├── start.sh # Startskript für macOS
├── install/ # Installationsskripte
│ ├── install_windows.ps1
│ └── install_macos.sh
├── requirements.txt # Benötigte Python-Bibliotheken
└── README.md # Diese Datei
### Voraussetzungen
- Python 3.11 oder neuer
- Virtuelle Umgebung empfohlen
### Setup
```bash
python -m venv .venv
source .venv/bin/activate # macOS / Linux
# .venv\Scripts\activate # Windows
pip install -r requirements.txt
```
### Starten
```bash
python main.py
```
### Build (Standalone-Executable)
```bash
python build.py
```
Die fertige Datei liegt anschließend im `dist/`-Verzeichnis.
---
## ️ Hinweise
- Nur `.wav`-Dateien werden akzeptiert.
- Cover müssen eine `.jpg`- oder `.jpeg`-Datei sein.
- Export erfolgt im `.mp3`-Format, z.B. benannt nach dem Originaldateinamen.
- Metadaten können vor jeder Konvertierung überprüft und angepasst werden.
## 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.
+78
View File
@@ -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()
+7
View File
@@ -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"
}
-58
View File
@@ -1,58 +0,0 @@
# fcco-Converter.spec
# Für lokalen Build auf Windows: pyinstaller fcco-Converter.spec
block_cipher = None
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[
('icon.ico', '.'), # Icon miteinbinden
('settings.json', '.'), # Standard-Settings
],
hiddenimports=[
'mutagen',
'mutagen.easyid3',
'mutagen.id3',
'pydub',
'tkinter',
'tkinter.ttk',
'tkinter.filedialog',
'tkinter.messagebox',
'tkinter.simpledialog',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zlib, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='fcco-Converter',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False, # Kein Konsolenfenster (GUI-App)
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='icon.ico',
)
+1
View File
@@ -0,0 +1 @@
# fcco-Converter V2
+185
View File
@@ -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)
+299
View File
@@ -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 010 (wir mappen 09 auf 110)
q = min(10, max(0, 10 - int(vbr_quality)))
cmd.extend(["-q:a", str(q)])
else:
cmd.extend(["-b:a", f"{bitrate}k"])
elif output_format == "wav":
cmd.extend(["-c:a", "pcm_s16le"])
# Sample-Rate
if sample_rate != "Original":
cmd.extend(["-ar", sample_rate])
# Kanäle
if channels == "Mono":
cmd.extend(["-ac", "1"])
elif channels == "Stereo":
cmd.extend(["-ac", "2"])
# Überschreiben
if overwrite:
cmd.append("-y")
else:
cmd.append("-n")
cmd.append(str(output_path))
return cmd
def convert_single_file(
input_path: Path,
output_path: Path,
ffmpeg_path: str,
ffprobe_path: str,
audio_settings: dict | None = None,
threads: int = 0,
progress_callback: Callable[[int], None] | None = None,
) -> None:
"""Konvertiert eine einzelne Audiodatei.
Args:
input_path: Pfad zur Quelldatei.
output_path: Pfad für die Zieldatei.
ffmpeg_path: Pfad zur ffmpeg-Binary.
ffprobe_path: Pfad zur ffprobe-Binary.
audio_settings: Dict mit Audio-Konfiguration (aus config).
threads: Anzahl der Threads (0 = auto).
progress_callback: Wird mit Prozent (0100) aufgerufen.
Raises:
RuntimeError: Bei Fehlern in der Konvertierung.
"""
if audio_settings is None:
audio_settings = {"output_format": "mp3", "bitrate": "192", "encoding_mode": "CBR"}
output_path.parent.mkdir(parents=True, exist_ok=True)
duration = get_duration(input_path, ffprobe_path)
if duration <= 0:
raise RuntimeError(f"Dauer der Datei konnte nicht ermittelt werden: {input_path.name}")
cmd = _build_ffmpeg_cmd(ffmpeg_path, input_path, output_path, audio_settings, threads)
process = subprocess.Popen(
cmd,
stderr=subprocess.PIPE,
stdout=subprocess.DEVNULL,
universal_newlines=True,
)
while True:
line = process.stderr.readline()
if not line:
break
if "time=" in line and progress_callback:
try:
time_str = line.split("time=")[1].split(" ")[0]
h, m, s = map(float, time_str.split(":"))
current_time = h * 3600 + m * 60 + s
percentage = min(int((current_time / duration) * 100), 100)
progress_callback(percentage)
except (ValueError, IndexError):
pass
process.wait()
if process.returncode != 0:
raise RuntimeError(f"FFmpeg-Fehler (Code {process.returncode}) bei: {input_path.name}")
def convert_file_entry(
entry: dict,
index: int,
export_dir: Path,
meta: dict,
use_subdirs: bool,
ffmpeg_path: str,
ffprobe_path: str,
audio_settings: dict | None = None,
threads: int = 0,
progress_callback: Callable[[int, int, str], None] | None = None,
) -> 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)
+934
View File
@@ -0,0 +1,934 @@
"""
fcco-Converter V2 GUI-Modul
Moderne GUI mit CustomTkinter, optimiert für macOS und Windows.
Bekannte macOS-Probleme und deren Lösungen:
- Buttons brauchen mehrere Klicks: Gelöst durch globales Fokus-Binding
und Vermeidung von grid_propagate(False) / fixen Höhen auf Containern.
- grab_set() auf Dialogen: Komplett entfernt auf macOS.
"""
import os
import platform
import threading
from datetime import datetime
from pathlib import Path
from tkinter import filedialog, messagebox
import customtkinter as ctk
from .config import (
DEFAULT_EXPORT_PATHS,
BITRATE_OPTIONS,
SAMPLERATE_OPTIONS,
CHANNEL_OPTIONS,
ENCODING_MODE_OPTIONS,
VBR_QUALITY_OPTIONS,
OUTPUT_FORMAT_OPTIONS,
load_settings,
save_settings,
save_export_history,
T,
APP_INFO,
)
from .converter import run_batch_conversion, _ensure_ffmpeg
from .metadata import (
generate_album_name,
generate_folder_name,
generate_date_part,
extract_event_from_folder,
)
from .updater import (
get_current_version,
check_for_updates,
download_and_install_update,
)
from .icons import IconSet
IS_MACOS = platform.system() == "Darwin"
# =========================================================================
# Farbschema
# =========================================================================
# (Farben werden jetzt aus config.T importiert)
# =========================================================================
# App
# =========================================================================
class App(ctk.CTk):
def __init__(self):
super().__init__()
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
self.title(f"{APP_INFO['app_name']} V{APP_INFO['app_version']}")
self.geometry("900x700")
self.minsize(700, 500)
self.configure(fg_color=T["bg"])
# Icon (nur Windows)
icon = Path(__file__).parent.parent / "icon.ico"
if icon.exists() and not IS_MACOS:
try:
self.iconbitmap(str(icon))
except Exception:
pass
self.icons = IconSet(size=(18, 18))
# State
self.settings = load_settings()
self.file_entries: list[dict] = []
self.base_folder: Path | None = None
self.is_converting = False
self._ffmpeg_ready = False
# FFmpeg im Hintergrund laden
threading.Thread(target=self._preload_ffmpeg, daemon=True).start()
# Layout
self._build()
# ---- macOS Fokus-Fix ----
# Jeder Klick irgendwo im Fenster erzwingt den Fokus auf das
# angeklickte Widget. Das behebt das Problem, dass Buttons
# auf macOS mehrfach geklickt werden müssen.
self.bind_all("<Button-1>", self._on_click, add="+")
self.after(100, self._activate)
self.protocol("WM_DELETE_WINDOW", self._on_close)
# ----- Fokus-Management (behebt macOS-Bug) -----
def _on_click(self, event):
"""Erzwingt Fokus auf das geklickte Widget."""
try:
w = event.widget
if w and w.winfo_exists():
w.focus_set()
except Exception:
pass
def _activate(self):
"""Bringt das Fenster nach vorne und aktiviert es."""
self.lift()
self.attributes("-topmost", True)
self.after(50, lambda: self.attributes("-topmost", False))
self.focus_force()
def _btn(self, parent, text, command, **kwargs):
btn = ctk.CTkButton(parent, text=text, **kwargs)
if IS_MACOS and command:
# macOS Bugfix: Direkter Bind statt Canvas-Command
btn.bind("<ButtonRelease-1>", lambda e: self.after(10, command))
elif command:
btn.configure(command=command)
return btn
def _preload_ffmpeg(self):
try:
_ensure_ffmpeg()
self._ffmpeg_ready = True
self.after(0, lambda: self._set_status("Bereit", T["success"]))
except Exception as e:
self.after(0, lambda: self._set_status(f"FFmpeg-Fehler: {e}", T["error"]))
def _set_status(self, text, color=None):
self.status_lbl.configure(text=text, text_color=color or T["muted"])
# =================================================================
# Layout
# =================================================================
def _build(self):
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(2, weight=1)
self._make_header()
self._make_toolbar()
self._make_filelist()
self._make_progress()
self._make_footer()
def _make_header(self):
h = ctk.CTkFrame(self, fg_color=T["surface"], corner_radius=0)
h.grid(row=0, column=0, sticky="ew")
h.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(h, text="fcco Converter",
font=ctk.CTkFont(size=20, weight="bold"),
text_color=T["text"]).grid(row=0, column=0, padx=(20, 0), pady=12, sticky="w")
ctk.CTkLabel(h, text=f"v{get_current_version()}",
font=ctk.CTkFont(size=12), text_color=T["muted"]
).grid(row=0, column=1, padx=(8, 0), pady=12, sticky="w")
self.update_btn = self._btn(
h, text=" Update prüfen", command=self._check_updates,
image=self.icons.refresh, width=150, height=32,
fg_color=T["btn2"], hover_color=T["btn2_h"],
border_width=1, border_color=T["border"],
text_color=T["text2"], font=ctk.CTkFont(size=12))
self.update_btn.grid(row=0, column=2, padx=(0, 8), pady=12)
self._btn(
h, text="", command=self._open_settings,
image=self.icons.settings, width=34, height=32,
fg_color=T["btn2"], hover_color=T["btn2_h"],
border_width=1, border_color=T["border"]
).grid(row=0, column=3, padx=(0, 20), pady=12)
def _make_toolbar(self):
tb = ctk.CTkFrame(self, fg_color="transparent")
tb.grid(row=1, column=0, sticky="ew", padx=20, pady=(10, 4))
tb.grid_columnconfigure(2, weight=1)
self._btn(
tb, text=" Dateien", command=self._pick_files,
image=self.icons.file_add, width=130, height=34,
fg_color=T["btn2"], hover_color=T["btn2_h"],
border_width=1, border_color=T["border"],
text_color=T["text"], font=ctk.CTkFont(size=13)
).grid(row=0, column=0, padx=(0, 6))
self._btn(
tb, text=" Ordner", command=self._pick_folder,
image=self.icons.folder, width=130, height=34,
fg_color=T["btn2"], hover_color=T["btn2_h"],
border_width=1, border_color=T["border"],
text_color=T["text"], font=ctk.CTkFont(size=13)
).grid(row=0, column=1, padx=(0, 6))
self.count_lbl = ctk.CTkLabel(tb, text="Keine Dateien",
text_color=T["muted"], font=ctk.CTkFont(size=12))
self.count_lbl.grid(row=0, column=2, sticky="w", padx=(10, 0))
self.conv_btn = self._btn(
tb, text=" Konvertieren", command=self._convert,
image=self.icons.play, width=160, height=36,
fg_color=T["btn2"], hover_color=T["btn2_h"],
text_color="#ffffff", font=ctk.CTkFont(size=14, weight="bold"))
self.conv_btn.grid(row=0, column=3, sticky="e")
def _make_filelist(self):
box = ctk.CTkFrame(self, fg_color=T["surface"],
border_width=1, border_color=T["border"], corner_radius=8)
box.grid(row=2, column=0, sticky="nsew", padx=20, pady=6)
box.grid_columnconfigure(0, weight=1)
box.grid_rowconfigure(1, weight=1)
hdr = ctk.CTkFrame(box, fg_color="transparent")
hdr.grid(row=0, column=0, sticky="ew", padx=12, pady=(8, 0))
hdr.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(hdr, text="Dateien",
font=ctk.CTkFont(size=13, weight="bold"),
text_color=T["text"]).grid(row=0, column=0, sticky="w")
self.info_lbl = ctk.CTkLabel(hdr, text="", font=ctk.CTkFont(size=11),
text_color=T["muted"])
self.info_lbl.grid(row=0, column=1, sticky="e")
self.scroll = ctk.CTkScrollableFrame(box, fg_color="transparent",
scrollbar_button_color=T["elevated"],
scrollbar_button_hover_color=T["border"])
self.scroll.grid(row=1, column=0, sticky="nsew", padx=6, pady=(4, 8))
self.scroll.grid_columnconfigure(1, weight=1)
self.rows: list[dict] = []
def _make_progress(self):
pf = ctk.CTkFrame(self, fg_color="transparent")
pf.grid(row=3, column=0, sticky="ew", padx=20, pady=(2, 0))
pf.grid_columnconfigure(0, weight=1)
self.total_bar = ctk.CTkProgressBar(pf, height=6,
progress_color=T["accent"], fg_color=T["elevated"], corner_radius=3)
self.total_bar.grid(row=0, column=0, sticky="ew")
self.total_bar.set(0)
self.prog_lbl = ctk.CTkLabel(pf, text="", font=ctk.CTkFont(size=11),
text_color=T["muted"])
self.prog_lbl.grid(row=1, column=0, sticky="w", pady=(2, 0))
def _make_footer(self):
f = ctk.CTkFrame(self, fg_color=T["surface"], corner_radius=0)
f.grid(row=4, column=0, sticky="ew")
self.status_lbl = ctk.CTkLabel(f, text="FFmpeg wird geladen...",
font=ctk.CTkFont(size=11), text_color=T["muted"])
self.status_lbl.pack(side="left", padx=20, pady=6)
audio = self.settings.get("audio", {})
self.audio_lbl = ctk.CTkLabel(f, text=self._audio_summary(audio),
font=ctk.CTkFont(size=11), text_color=T["muted"])
self.audio_lbl.pack(side="right", padx=20, pady=6)
def _audio_summary(self, audio: dict) -> str:
fmt = audio.get("output_format", "mp3").upper()
mode = audio.get("encoding_mode", "CBR")
if mode == "CBR":
s = f"{fmt} CBR {audio.get('bitrate', '192')}k"
else:
s = f"{fmt} VBR Q{audio.get('vbr_quality', '2')}"
sr = audio.get("sample_rate", "Original")
if sr != "Original":
s += f" {sr}Hz"
ch = audio.get("channels", "Original")
if ch != "Original":
s += f" {ch}"
return s
# =================================================================
# Dateien
# =================================================================
def _pick_files(self):
p = filedialog.askopenfilenames(filetypes=[("WAV", "*.wav"), ("Alle", "*.*")])
if p:
self.base_folder = None
self.file_entries = [{"full_path": str(x), "rel_dir": ""} for x in p]
self._refresh_list()
self.after(30, self._activate)
def _pick_folder(self):
d = filedialog.askdirectory()
if d:
dp = Path(d)
self.base_folder = dp
self.file_entries = []
for r, _, fs in os.walk(dp):
for fn in fs:
if fn.lower().endswith(".wav"):
fp = Path(r) / fn
rel = str(Path(r).relative_to(dp))
self.file_entries.append({"full_path": str(fp), "rel_dir": "" if rel == "." else rel})
self._refresh_list()
self.after(30, self._activate)
def _refresh_list(self):
for w in self.rows:
w["fr"].destroy()
self.rows.clear()
self.file_entries.sort(key=lambda x: Path(x["full_path"]).name.lower())
for i, e in enumerate(self.file_entries):
name = Path(e["full_path"]).name
disp = f"{e['rel_dir']}/{name}" if e["rel_dir"] else name
fr = ctk.CTkFrame(self.scroll, fg_color="transparent")
fr.grid(row=i, column=0, sticky="ew", pady=1, columnspan=4)
fr.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(fr, text=f"{i+1}", font=ctk.CTkFont(size=11),
text_color=T["muted"], width=30).grid(row=0, column=0, padx=(4, 6))
ctk.CTkLabel(fr, text=disp, font=ctk.CTkFont(size=12), anchor="w",
text_color=T["text"]).grid(row=0, column=1, sticky="w")
sl = ctk.CTkLabel(fr, text="Wartet", font=ctk.CTkFont(size=11),
text_color=T["muted"], width=70)
sl.grid(row=0, column=2, padx=(8, 4))
pb = ctk.CTkProgressBar(fr, width=90, height=4,
progress_color=T["accent"], fg_color=T["elevated"], corner_radius=2)
pb.grid(row=0, column=3, padx=(0, 8))
pb.set(0)
self.rows.append({"fr": fr, "sl": sl, "pb": pb})
n = len(self.file_entries)
self.count_lbl.configure(text=f"{n} Datei{'en' if n != 1 else ''}")
self.info_lbl.configure(text=f"{n} Einträge")
# =================================================================
# Konvertierung
# =================================================================
def _convert(self):
if not self.file_entries:
messagebox.showwarning("Keine Dateien", "Bitte zuerst Dateien auswählen.")
self.after(30, self._activate)
return
if self.is_converting:
return
if not self._ffmpeg_ready:
messagebox.showwarning("FFmpeg", "FFmpeg wird noch geladen.")
self.after(30, self._activate)
return
meta = self._meta_dialog()
if not meta:
return
ctype = meta.get("type", "Sonstiges")
event = meta.get("event", "").strip()
dpart = meta.get("date_part", generate_date_part())
if ctype in self.settings.get("export_paths", {}) and ctype != "Sonstiges":
bd = Path(self.settings["export_paths"][ctype])
bd.mkdir(parents=True, exist_ok=True)
ed = bd / generate_folder_name(dpart, event)
ed.mkdir(parents=True, exist_ok=True)
else:
chosen = filedialog.askdirectory(title="Export-Ordner wählen")
if not chosen:
return
ed = Path(chosen)
self.settings["last_export_dir"] = str(ed)
save_settings(self.settings)
subdirs = self.base_folder is not None
self.is_converting = True
self.conv_btn.configure(state="disabled", text=" Konvertiert...")
self.total_bar.set(0)
self.prog_lbl.configure(text="Konvertierung gestartet...")
self._set_status("Konvertierung...", T["warn"])
workers = self.settings.get("threads", 0) or os.cpu_count() or 1
audio = self.settings.get("audio", {})
total = len(self.file_entries)
done = {"n": 0}
def on_prog(idx, pct, fn):
self.after(0, lambda: self._file_prog(idx, pct))
if pct >= 100:
done["n"] += 1
r = done["n"] / total
self.after(0, lambda r=r, c=done["n"]: (
self.total_bar.set(r),
self.prog_lbl.configure(text=f"{c} / {total}")
))
def on_done(ok, errs):
self.after(0, lambda: self._conv_done(ok, errs, ed))
threading.Thread(target=run_batch_conversion, daemon=True, kwargs={
"entries": self.file_entries, "export_dir": ed, "meta": meta,
"use_subdirs": subdirs, "audio_settings": audio,
"max_workers": workers, "progress_callback": on_prog,
"completion_callback": on_done,
}).start()
def _file_prog(self, idx, pct):
if 0 <= idx < len(self.rows):
r = self.rows[idx]
r["pb"].set(pct / 100)
if pct >= 100:
r["sl"].configure(text="Fertig", text_color=T["success"])
else:
r["sl"].configure(text=f"{pct}%", text_color=T["accent"])
def _conv_done(self, ok, errs, ed):
self.is_converting = False
self.conv_btn.configure(state="normal", text=" Konvertieren")
self.total_bar.set(1.0)
self._set_status("Bereit", T["success"])
for fn, _ in errs:
for i, e in enumerate(self.file_entries):
if Path(e["full_path"]).name == fn and i < len(self.rows):
self.rows[i]["sl"].configure(text="Fehler", text_color=T["error"])
break
save_export_history({
"datum": datetime.now().strftime("%d.%m.%Y %H:%M"),
"ordner": str(ed), "dateien": ok,
})
msg = f"{len(ok)} Dateien konvertiert."
if errs:
msg += f"\n{len(errs)} Fehler:"
for fn, err in errs[:5]:
msg += f"\n {fn}: {err}"
res = messagebox.askquestion("Fertig", f"{msg}\n\nProgramm schließen?")
if res == "yes":
self.destroy()
else:
self.after(30, self._activate)
# =================================================================
# Metadaten + Export Dialog (KEIN Scrolling)
# =================================================================
def _meta_dialog(self) -> dict | None:
meta = self.settings.get("metadata", {}).copy()
audio = self.settings.get("audio", {})
if self.file_entries:
fp = Path(self.file_entries[0]["full_path"])
dpart, veranst = extract_event_from_folder(fp)
if not dpart:
dpart = generate_date_part()
else:
dpart = generate_date_part()
veranst = ""
meta["album"] = generate_album_name(veranst)
meta["year"] = str(datetime.now().year)
d = ctk.CTkToplevel(self)
d.title("Metadaten und Export")
d.geometry("620x350")
d.transient(self)
d.resizable(False, False)
d.configure(fg_color=T["bg"])
result = {}
# ---- Kompaktes Layout ohne Scroll ----
main = ctk.CTkFrame(d, fg_color="transparent")
main.pack(fill="both", expand=True, padx=20, pady=(16, 8))
# --- Metadaten (links oben) ---
left = ctk.CTkFrame(main, fg_color="transparent")
left.pack(fill="x")
row0 = ctk.CTkFrame(left, fg_color="transparent")
row0.pack(fill="x", pady=(0, 6))
row0.grid_columnconfigure(0, weight=1)
row0.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(row0, text="Künstler", text_color=T["text2"],
font=ctk.CTkFont(size=12)).grid(row=0, column=0, sticky="w")
artist_e = ctk.CTkEntry(row0, fg_color=T["input"], border_color=T["border"],
text_color=T["text"])
artist_e.grid(row=1, column=0, sticky="ew", padx=(0, 8))
artist_e.insert(0, meta.get("artist", ""))
ctk.CTkLabel(row0, text="Album", text_color=T["text2"],
font=ctk.CTkFont(size=12)).grid(row=0, column=1, sticky="w")
album_e = ctk.CTkEntry(row0, fg_color=T["input"], border_color=T["border"],
text_color=T["text"])
album_e.grid(row=1, column=1, sticky="ew")
album_e.insert(0, meta.get("album", ""))
row1 = ctk.CTkFrame(left, fg_color="transparent")
row1.pack(fill="x", pady=(0, 6))
row1.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(row1, text="Jahr", text_color=T["text2"],
font=ctk.CTkFont(size=12)).grid(row=0, column=0, sticky="w")
year_e = ctk.CTkEntry(row1, width=80, fg_color=T["input"], border_color=T["border"],
text_color=T["text"])
year_e.grid(row=1, column=0, sticky="w", padx=(0, 8))
year_e.insert(0, meta.get("year", ""))
ctk.CTkLabel(row1, text="Cover-Bild", text_color=T["text2"],
font=ctk.CTkFont(size=12)).grid(row=0, column=1, sticky="w")
cover_fr = ctk.CTkFrame(row1, fg_color="transparent")
cover_fr.grid(row=1, column=1, sticky="ew")
cover_fr.grid_columnconfigure(0, weight=1)
cover_e = ctk.CTkEntry(cover_fr, fg_color=T["input"], border_color=T["border"],
text_color=T["text"])
cover_e.grid(row=0, column=0, sticky="ew")
cover_e.insert(0, meta.get("cover", ""))
def _bc():
p = filedialog.askopenfilename(filetypes=[("Bilder", "*.jpg *.jpeg *.png")])
if p:
cover_e.delete(0, "end")
cover_e.insert(0, p)
d.after(30, lambda: (d.lift(), d.focus_force()))
self._btn(cover_fr, text="", command=_bc, image=self.icons.browse,
width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],
border_width=1, border_color=T["border"]
).grid(row=0, column=1, padx=(4, 0))
# --- Trennlinie ---
ctk.CTkFrame(main, height=1, fg_color=T["border"]).pack(fill="x", pady=10)
# --- Mitte: Veranstaltung + Datum nebeneinander ---
mid = ctk.CTkFrame(main, fg_color="transparent")
mid.pack(fill="x")
mid.grid_columnconfigure(0, weight=0)
mid.grid_columnconfigure(1, weight=0)
mid.grid_columnconfigure(2, weight=1)
# Veranstaltungstyp
ctk.CTkLabel(mid, text="Typ", text_color=T["text2"],
font=ctk.CTkFont(size=12, weight="bold")).grid(row=0, column=0, sticky="w", pady=(0, 4))
ctype_var = ctk.StringVar(value="Gottesdienst")
for i, opt in enumerate(["Gottesdienst", "Hochzeit", "Weissagung", "Jugend", "Sonstiges"]):
ctk.CTkRadioButton(mid, text=opt, variable=ctype_var, value=opt,
fg_color=T["btn2"], hover_color=T["btn2_h"], checkmark_color="#ffffff",
border_color=T["border"], text_color=T["text"],
font=ctk.CTkFont(size=12)
).grid(row=i+1, column=0, sticky="w", pady=1, padx=(0, 24))
# Datum + Veranstaltungsname
ctk.CTkLabel(mid, text="Datum", text_color=T["text2"],
font=ctk.CTkFont(size=12)).grid(row=0, column=1, sticky="w")
date_e = ctk.CTkEntry(mid, width=120, fg_color=T["input"], border_color=T["border"],
text_color=T["text"])
date_e.grid(row=1, column=1, sticky="w", padx=(0, 12))
date_e.insert(0, dpart)
ctk.CTkLabel(mid, text="Veranstaltung (opt.)", text_color=T["text2"],
font=ctk.CTkFont(size=12)).grid(row=0, column=2, sticky="w")
event_e = ctk.CTkEntry(mid, fg_color=T["input"], border_color=T["border"],
text_color=T["text"])
event_e.grid(row=1, column=2, sticky="ew")
event_e.insert(0, veranst)
def _upd_album(*_):
album_e.delete(0, "end")
album_e.insert(0, generate_album_name(event_e.get().strip()))
event_e.bind("<KeyRelease>", _upd_album)
# --- Buttons ---
bf = ctk.CTkFrame(d, fg_color="transparent")
bf.pack(fill="x", padx=20, pady=(8, 16))
self._btn(bf, text="Abbrechen", command=d.destroy, width=110, height=34,
fg_color=T["btn2"], hover_color=T["btn2_h"],
border_width=1, border_color=T["border"],
text_color=T["text2"]
).pack(side="left")
def _ok():
result["artist"] = artist_e.get()
result["album"] = album_e.get()
result["year"] = year_e.get()
result["cover"] = cover_e.get()
result["type"] = ctype_var.get()
result["event"] = event_e.get().strip()
result["date_part"] = date_e.get().strip()
d.destroy()
self._btn(bf, text=" Konvertieren starten", command=_ok,
image=self.icons.play, width=180, height=34,
fg_color=T["btn2"], hover_color=T["btn2_h"],
text_color="#ffffff", font=ctk.CTkFont(weight="bold")
).pack(side="right")
# Fokus (KEIN grab_set auf macOS!)
d.after(50, lambda: (d.lift(), d.focus_force()))
if not IS_MACOS:
d.after(200, lambda: d.grab_set())
self.wait_window(d)
self.after(30, self._activate)
return result if result else None
# =================================================================
# Einstellungen
# =================================================================
def _open_settings(self):
w = ctk.CTkToplevel(self)
w.title("Einstellungen")
w.geometry("560x580")
w.transient(self)
w.resizable(False, False)
w.configure(fg_color=T["bg"])
tabs = ctk.CTkTabview(w, fg_color=T["surface"],
segmented_button_fg_color=T["elevated"],
segmented_button_selected_color=T["border"],
segmented_button_selected_hover_color=T["btn2_h"],
segmented_button_unselected_color=T["elevated"],
segmented_button_unselected_hover_color=T["btn2_h"],
text_color=T["text"])
tabs.pack(fill="both", expand=True, padx=12, pady=(12, 4))
t1 = tabs.add("Allgemein")
t2 = tabs.add("Audio")
t3 = tabs.add("Metadaten")
t4 = tabs.add("Export-Pfade")
wg = {} # Widget-Sammlung
# == Allgemein ==
s1 = ctk.CTkScrollableFrame(t1, fg_color="transparent")
s1.pack(fill="both", expand=True, padx=4, pady=4)
self._slabel(s1, "Leistung")
ctk.CTkLabel(s1, text="Threads (0 = automatisch)", text_color=T["text2"],
font=ctk.CTkFont(size=12)).pack(anchor="w")
te = ctk.CTkEntry(s1, width=100, fg_color=T["input"], border_color=T["border"],
text_color=T["text"])
te.pack(anchor="w", pady=(2, 10))
te.insert(0, str(self.settings.get("threads", 0)))
wg["threads"] = te
self._slabel(s1, "Darstellung")
ctk.CTkLabel(s1, text="Theme", text_color=T["text2"],
font=ctk.CTkFont(size=12)).pack(anchor="w")
av = ctk.StringVar(value=self.settings.get("appearance_mode", "Dark"))
ctk.CTkOptionMenu(s1, values=["Dark", "Light", "System"], variable=av,
fg_color=T["input"], button_color=T["btn2"],
button_hover_color=T["btn2_h"], text_color=T["text"],
command=lambda v: ctk.set_appearance_mode(v)
).pack(anchor="w", pady=(2, 10))
wg["appearance"] = av
self._slabel(s1, "Updates")
uv = ctk.BooleanVar(value=self.settings.get("check_updates_on_start", True))
ctk.CTkCheckBox(s1, text="Beim Start auf Updates prüfen", variable=uv,
fg_color=T["btn2"], hover_color=T["btn2_h"], checkmark_color="#ffffff",
border_color=T["border"], text_color=T["text"],
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=4)
wg["auto_update"] = uv
# == Audio ==
s2 = ctk.CTkScrollableFrame(t2, fg_color="transparent")
s2.pack(fill="both", expand=True, padx=4, pady=4)
self._slabel(s2, "Ausgabeformat")
fv = ctk.StringVar(value=self.settings.get("audio", {}).get("output_format", "mp3"))
ctk.CTkOptionMenu(s2, values=OUTPUT_FORMAT_OPTIONS, variable=fv,
fg_color=T["input"], button_color=T["btn2"],
button_hover_color=T["btn2_h"], text_color=T["text"]
).pack(anchor="w", pady=(2, 10))
wg["fmt"] = fv
self._slabel(s2, "Encoding")
mv = ctk.StringVar(value=self.settings.get("audio", {}).get("encoding_mode", "CBR"))
ctk.CTkOptionMenu(s2, values=ENCODING_MODE_OPTIONS, variable=mv,
fg_color=T["input"], button_color=T["btn2"],
button_hover_color=T["btn2_h"], text_color=T["text"]
).pack(anchor="w", pady=(2, 6))
wg["mode"] = mv
ctk.CTkLabel(s2, text="Bitrate (kbit/s, nur CBR)", text_color=T["text2"],
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(4, 0))
bv = ctk.StringVar(value=self.settings.get("audio", {}).get("bitrate", "192"))
ctk.CTkOptionMenu(s2, values=BITRATE_OPTIONS, variable=bv,
fg_color=T["input"], button_color=T["btn2"],
button_hover_color=T["btn2_h"], text_color=T["text"]
).pack(anchor="w", pady=(2, 6))
wg["br"] = bv
ctk.CTkLabel(s2, text="VBR-Qualität (0=best, 9=klein, nur VBR)", text_color=T["text2"],
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(4, 0))
vv = ctk.StringVar(value=self.settings.get("audio", {}).get("vbr_quality", "2"))
ctk.CTkOptionMenu(s2, values=VBR_QUALITY_OPTIONS, variable=vv,
fg_color=T["input"], button_color=T["btn2"],
button_hover_color=T["btn2_h"], text_color=T["text"]
).pack(anchor="w", pady=(2, 10))
wg["vbr"] = vv
self._slabel(s2, "Sampling")
ctk.CTkLabel(s2, text="Sample-Rate (Hz)", text_color=T["text2"],
font=ctk.CTkFont(size=12)).pack(anchor="w")
sv = ctk.StringVar(value=self.settings.get("audio", {}).get("sample_rate", "Original"))
ctk.CTkOptionMenu(s2, values=SAMPLERATE_OPTIONS, variable=sv,
fg_color=T["input"], button_color=T["btn2"],
button_hover_color=T["btn2_h"], text_color=T["text"]
).pack(anchor="w", pady=(2, 6))
wg["sr"] = sv
ctk.CTkLabel(s2, text="Kanäle", text_color=T["text2"],
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(4, 0))
cv = ctk.StringVar(value=self.settings.get("audio", {}).get("channels", "Original"))
ctk.CTkOptionMenu(s2, values=CHANNEL_OPTIONS, variable=cv,
fg_color=T["input"], button_color=T["btn2"],
button_hover_color=T["btn2_h"], text_color=T["text"]
).pack(anchor="w", pady=(2, 10))
wg["ch"] = cv
self._slabel(s2, "Optionen")
ov = ctk.BooleanVar(value=self.settings.get("audio", {}).get("overwrite_existing", True))
ctk.CTkCheckBox(s2, text="Bestehende Dateien überschreiben", variable=ov,
fg_color=T["btn2"], hover_color=T["btn2_h"], checkmark_color="#ffffff",
border_color=T["border"], text_color=T["text"],
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=3)
wg["ow"] = ov
# == Metadaten ==
s3 = ctk.CTkScrollableFrame(t3, fg_color="transparent")
s3.pack(fill="both", expand=True, padx=4, pady=4)
self._slabel(s3, "Standard-Metadaten")
ctk.CTkLabel(s3, text="Künstler", text_color=T["text2"],
font=ctk.CTkFont(size=12)).pack(anchor="w")
mae = ctk.CTkEntry(s3, width=380, fg_color=T["input"], border_color=T["border"],
text_color=T["text"])
mae.pack(anchor="w", pady=(2, 8))
mae.insert(0, self.settings.get("metadata", {}).get("artist", ""))
wg["m_artist"] = mae
ctk.CTkLabel(s3, text="Standard-Cover", text_color=T["text2"],
font=ctk.CTkFont(size=12)).pack(anchor="w")
mcf = ctk.CTkFrame(s3, fg_color="transparent")
mcf.pack(anchor="w", fill="x", pady=(2, 10))
mce = ctk.CTkEntry(mcf, width=340, fg_color=T["input"], border_color=T["border"],
text_color=T["text"])
mce.pack(side="left")
mce.insert(0, self.settings.get("metadata", {}).get("cover", ""))
wg["m_cover"] = mce
def _bc2():
p = filedialog.askopenfilename(filetypes=[("Bilder", "*.jpg *.jpeg *.png")])
if p:
mce.delete(0, "end")
mce.insert(0, p)
self._btn(mcf, text="", command=_bc2, image=self.icons.browse,
width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],
border_width=1, border_color=T["border"]
).pack(side="left", padx=(4, 0))
self._slabel(s3, "Automatisierung")
atv = ctk.BooleanVar(value=self.settings.get("metadata", {}).get("auto_track_number", True))
ctk.CTkCheckBox(s3, text="Tracknummer automatisch vergeben", variable=atv,
fg_color=T["btn2"], hover_color=T["btn2_h"], checkmark_color="#ffffff",
border_color=T["border"], text_color=T["text"],
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=3)
wg["auto_track"] = atv
aav = ctk.BooleanVar(value=self.settings.get("metadata", {}).get("auto_album_from_folder", True))
ctk.CTkCheckBox(s3, text="Albumname aus Ordnername ableiten", variable=aav,
fg_color=T["btn2"], hover_color=T["btn2_h"], checkmark_color="#ffffff",
border_color=T["border"], text_color=T["text"],
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=3)
wg["auto_album"] = aav
# == Export-Pfade ==
s4 = ctk.CTkScrollableFrame(t4, fg_color="transparent")
s4.pack(fill="both", expand=True, padx=4, pady=4)
self._slabel(s4, "Standard-Exportverzeichnisse")
pe = {}
for key in DEFAULT_EXPORT_PATHS:
ctk.CTkLabel(s4, text=key, text_color=T["text2"],
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(6, 0))
rf = ctk.CTkFrame(s4, fg_color="transparent")
rf.pack(anchor="w", fill="x", pady=(2, 2))
ent = ctk.CTkEntry(rf, width=360, fg_color=T["input"], border_color=T["border"],
text_color=T["text"])
ent.pack(side="left")
ent.insert(0, self.settings.get("export_paths", {}).get(key, DEFAULT_EXPORT_PATHS[key]))
def _br(e=ent):
p = filedialog.askdirectory()
if p:
e.delete(0, "end")
e.insert(0, p)
self._btn(rf, text="", command=_br, image=self.icons.browse,
width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],
border_width=1, border_color=T["border"]
).pack(side="left", padx=(4, 0))
pe[key] = ent
wg["paths"] = pe
# --- Buttons ---
bf = ctk.CTkFrame(w, fg_color="transparent")
bf.pack(fill="x", padx=12, pady=(4, 12))
self._btn(bf, text="Abbrechen", command=w.destroy, width=110, height=34,
fg_color=T["btn2"], hover_color=T["btn2_h"],
border_width=1, border_color=T["border"],
text_color=T["text2"]
).pack(side="left")
self._btn(bf, text=" Speichern", command=lambda: self._save_s(wg, w),
image=self.icons.save, width=140, height=34,
fg_color=T["btn2"], hover_color=T["btn2_h"],
text_color="#ffffff", font=ctk.CTkFont(weight="bold")
).pack(side="right")
w.after(50, lambda: (w.lift(), w.focus_force()))
if not IS_MACOS:
w.after(200, lambda: w.grab_set())
def _save_s(self, wg, win):
try:
th = int(wg["threads"].get())
if th < 0:
messagebox.showerror("Fehler", "Threads >= 0")
return
except ValueError:
messagebox.showerror("Fehler", "Ungültige Zahl")
return
self.settings["threads"] = th
self.settings["appearance_mode"] = wg["appearance"].get()
self.settings["check_updates_on_start"] = wg["auto_update"].get()
self.settings["audio"] = {
"output_format": wg["fmt"].get(),
"encoding_mode": wg["mode"].get(),
"bitrate": wg["br"].get(),
"vbr_quality": wg["vbr"].get(),
"sample_rate": wg["sr"].get(),
"channels": wg["ch"].get(),
"overwrite_existing": wg["ow"].get(),
}
self.settings["metadata"] = {
"artist": wg["m_artist"].get(),
"cover": wg["m_cover"].get(),
"auto_track_number": wg["auto_track"].get(),
"auto_album_from_folder": wg["auto_album"].get(),
}
self.settings["export_paths"] = {k: v.get() for k, v in wg["paths"].items()}
save_settings(self.settings)
self.audio_lbl.configure(text=self._audio_summary(self.settings["audio"]))
win.destroy()
self.after(30, self._activate)
# =================================================================
# Update
# =================================================================
def _check_updates(self):
self.update_btn.configure(state="disabled", text=" Prüfe...")
self._set_status("Suche Updates...", T["warn"])
def _do():
r = check_for_updates()
self.after(0, lambda: self._upd_done(r))
threading.Thread(target=_do, daemon=True).start()
def _upd_done(self, rel):
self.update_btn.configure(state="normal", text=" Update prüfen")
if not rel:
self._set_status("Neueste Version", T["success"])
return
tag = rel.get("tag_name", "?")
if messagebox.askyesno("Update", f"Version {tag} verfügbar.\nJetzt installieren?"):
self._do_update(rel)
self.after(30, self._activate)
def _do_update(self, rel):
self.update_btn.configure(state="disabled", text=" Lädt...")
def _p(pct, msg):
self.after(0, lambda: self._set_status(msg, T["warn"]))
self.after(0, lambda: self.total_bar.set(pct / 100))
def _go():
ok = download_and_install_update(rel, progress_callback=_p)
self.after(0, lambda: self._upd_fin(ok))
threading.Thread(target=_go, daemon=True).start()
def _upd_fin(self, ok):
self.update_btn.configure(state="normal", text=" Update prüfen")
if ok:
messagebox.showinfo("Update", "Bitte neu starten.")
else:
self._set_status("Update fehlgeschlagen", T["error"])
self.after(30, self._activate)
# =================================================================
# Helfer
# =================================================================
@staticmethod
def _slabel(parent, text):
ctk.CTkLabel(parent, text=text,
font=ctk.CTkFont(size=14, weight="bold"),
text_color=T["text"]).pack(anchor="w", pady=(10, 2))
def _on_close(self):
if self.is_converting:
if messagebox.askyesno("Beenden?", "Konvertierung läuft. Wirklich beenden?"):
self.destroy()
else:
self.destroy()
+304
View File
@@ -0,0 +1,304 @@
"""
fcco-Converter V2 Icon-Modul
Erzeugt saubere, professionelle Icons programmatisch mit PIL.
Keine externen Bilddateien nötig alle Icons sind Vektorgrafiken,
die in beliebiger Größe gerendert werden können.
"""
from PIL import Image, ImageDraw
import customtkinter as ctk
def _create_canvas(size: int = 24, bg: str | None = None) -> tuple[Image.Image, ImageDraw.ImageDraw]:
"""Erstellt eine transparente Zeichenfläche."""
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img, "RGBA")
return img, draw
def _hex_to_rgba(hex_color: str, alpha: int = 255) -> tuple[int, int, int, int]:
"""Konvertiert einen Hex-Farbwert zu RGBA."""
hex_color = hex_color.lstrip("#")
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
return (r, g, b, alpha)
def make_ctk_image(light_img: Image.Image, dark_img: Image.Image | None = None, size: tuple[int, int] = (20, 20)) -> ctk.CTkImage:
"""Erzeugt ein CTkImage aus PIL-Bildern."""
return ctk.CTkImage(
light_image=light_img,
dark_image=dark_img or light_img,
size=size,
)
# =========================================================================
# Icon-Generatoren
# =========================================================================
def icon_folder(color: str = "#e2e8f0", size: int = 24) -> Image.Image:
"""Ordner-Icon."""
img, draw = _create_canvas(size)
c = _hex_to_rgba(color)
s = size
# Ordner-Körper
draw.rounded_rectangle(
[s * 0.08, s * 0.25, s * 0.92, s * 0.85],
radius=s * 0.08, fill=c
)
# Tab oben
draw.rounded_rectangle(
[s * 0.08, s * 0.15, s * 0.45, s * 0.35],
radius=s * 0.06, fill=c
)
return img
def icon_file_add(color: str = "#e2e8f0", size: int = 24) -> Image.Image:
"""Datei-hinzufügen-Icon."""
img, draw = _create_canvas(size)
c = _hex_to_rgba(color)
s = size
lw = max(1, int(s * 0.08))
# Dokument
draw.rounded_rectangle(
[s * 0.17, s * 0.04, s * 0.71, s * 0.96],
radius=s * 0.06, outline=c, width=lw
)
# Plus-Zeichen
cx, cy = s * 0.44, s * 0.50
arm = s * 0.15
draw.line([(cx - arm, cy), (cx + arm, cy)], fill=c, width=lw)
draw.line([(cx, cy - arm), (cx, cy + arm)], fill=c, width=lw)
return img
def icon_play(color: str = "#e2e8f0", size: int = 24) -> Image.Image:
"""Play/Start-Icon (Dreieck)."""
img, draw = _create_canvas(size)
c = _hex_to_rgba(color)
s = size
# Dreieck (nach rechts zeigend)
points = [
(s * 0.25, s * 0.15),
(s * 0.80, s * 0.50),
(s * 0.25, s * 0.85),
]
draw.polygon(points, fill=c)
return img
def icon_settings(color: str = "#e2e8f0", size: int = 24) -> Image.Image:
"""Zahnrad/Einstellungen-Icon."""
img, draw = _create_canvas(size)
c = _hex_to_rgba(color)
s = size
lw = max(1, int(s * 0.08))
cx, cy = s * 0.5, s * 0.5
# Äußerer Ring
r_outer = s * 0.38
draw.ellipse(
[cx - r_outer, cy - r_outer, cx + r_outer, cy + r_outer],
outline=c, width=lw
)
# Innerer Punkt
r_inner = s * 0.12
draw.ellipse(
[cx - r_inner, cy - r_inner, cx + r_inner, cy + r_inner],
fill=c
)
# Zähne (6 Stück als kurze Linien)
import math
for i in range(6):
angle = math.pi * i / 3
x1 = cx + r_outer * math.cos(angle)
y1 = cy + r_outer * math.sin(angle)
x2 = cx + (r_outer + s * 0.12) * math.cos(angle)
y2 = cy + (r_outer + s * 0.12) * math.sin(angle)
draw.line([(x1, y1), (x2, y2)], fill=c, width=lw)
return img
def icon_refresh(color: str = "#e2e8f0", size: int = 24) -> Image.Image:
"""Refresh/Update-Icon (kreisförmiger Pfeil)."""
img, draw = _create_canvas(size)
c = _hex_to_rgba(color)
s = size
lw = max(1, int(s * 0.08))
# Kreisbogen
draw.arc(
[s * 0.15, s * 0.15, s * 0.85, s * 0.85],
start=30, end=330, fill=c, width=lw
)
# Pfeilspitze
import math
angle = math.radians(30)
cx, cy = s * 0.5, s * 0.5
r = s * 0.35
tip_x = cx + r * math.cos(angle)
tip_y = cy - r * math.sin(angle)
arrow_size = s * 0.15
points = [
(tip_x, tip_y),
(tip_x + arrow_size, tip_y + arrow_size * 0.3),
(tip_x + arrow_size * 0.3, tip_y + arrow_size),
]
draw.polygon(points, fill=c)
return img
def icon_save(color: str = "#e2e8f0", size: int = 24) -> Image.Image:
"""Speichern-Icon (Diskette)."""
img, draw = _create_canvas(size)
c = _hex_to_rgba(color)
s = size
lw = max(1, int(s * 0.08))
# Äußeres Rechteck
draw.rounded_rectangle(
[s * 0.10, s * 0.10, s * 0.90, s * 0.90],
radius=s * 0.06, outline=c, width=lw
)
# Inneres Rechteck (Etikett)
draw.rectangle(
[s * 0.30, s * 0.55, s * 0.70, s * 0.85],
outline=c, width=lw
)
# Oberer Bereich (Schieber)
draw.rectangle(
[s * 0.30, s * 0.10, s * 0.70, s * 0.38],
fill=c
)
return img
def icon_browse(color: str = "#e2e8f0", size: int = 24) -> Image.Image:
"""Durchsuchen-Icon (Lupe)."""
img, draw = _create_canvas(size)
c = _hex_to_rgba(color)
s = size
lw = max(1, int(s * 0.08))
# Kreis
draw.ellipse(
[s * 0.12, s * 0.08, s * 0.62, s * 0.58],
outline=c, width=lw
)
# Griff
draw.line(
[(s * 0.55, s * 0.55), (s * 0.88, s * 0.88)],
fill=c, width=max(2, int(s * 0.10))
)
return img
def icon_check(color: str = "#34d399", size: int = 24) -> Image.Image:
"""Häkchen-Icon."""
img, draw = _create_canvas(size)
c = _hex_to_rgba(color)
s = size
lw = max(2, int(s * 0.10))
draw.line(
[(s * 0.18, s * 0.52), (s * 0.40, s * 0.75), (s * 0.82, s * 0.25)],
fill=c, width=lw, joint="curve"
)
return img
def icon_cross(color: str = "#f87171", size: int = 24) -> Image.Image:
"""Kreuz/Fehler-Icon."""
img, draw = _create_canvas(size)
c = _hex_to_rgba(color)
s = size
lw = max(2, int(s * 0.10))
draw.line([(s * 0.22, s * 0.22), (s * 0.78, s * 0.78)], fill=c, width=lw)
draw.line([(s * 0.78, s * 0.22), (s * 0.22, s * 0.78)], fill=c, width=lw)
return img
def icon_music(color: str = "#e2e8f0", size: int = 24) -> Image.Image:
"""Musik/Audio-Icon (Notensymbol)."""
img, draw = _create_canvas(size)
c = _hex_to_rgba(color)
s = size
lw = max(1, int(s * 0.08))
# Notenhals
draw.line([(s * 0.60, s * 0.15), (s * 0.60, s * 0.70)], fill=c, width=lw)
# Notenkopf
draw.ellipse(
[s * 0.35, s * 0.60, s * 0.65, s * 0.85],
fill=c
)
# Fähnchen
draw.arc(
[s * 0.55, s * 0.12, s * 0.88, s * 0.45],
start=270, end=90, fill=c, width=lw
)
return img
def icon_info(color: str = "#e2e8f0", size: int = 24) -> Image.Image:
"""Info-Icon (i im Kreis)."""
img, draw = _create_canvas(size)
c = _hex_to_rgba(color)
s = size
lw = max(1, int(s * 0.08))
# Kreis
draw.ellipse(
[s * 0.10, s * 0.10, s * 0.90, s * 0.90],
outline=c, width=lw
)
# Punkt oben
draw.ellipse(
[s * 0.44, s * 0.22, s * 0.56, s * 0.34],
fill=c
)
# Strich unten
draw.line([(s * 0.50, s * 0.42), (s * 0.50, s * 0.75)], fill=c, width=lw)
return img
def icon_download(color: str = "#e2e8f0", size: int = 24) -> Image.Image:
"""Download-Icon (Pfeil nach unten)."""
img, draw = _create_canvas(size)
c = _hex_to_rgba(color)
s = size
lw = max(1, int(s * 0.08))
# Vertikale Linie
draw.line([(s * 0.50, s * 0.12), (s * 0.50, s * 0.62)], fill=c, width=lw)
# Pfeilspitze
draw.line([(s * 0.30, s * 0.48), (s * 0.50, s * 0.68)], fill=c, width=lw)
draw.line([(s * 0.70, s * 0.48), (s * 0.50, s * 0.68)], fill=c, width=lw)
# Unterstrich
draw.line([(s * 0.20, s * 0.85), (s * 0.80, s * 0.85)], fill=c, width=lw)
return img
# =========================================================================
# Vorgefertigte CTkImage-Sets (Light + Dark)
# =========================================================================
class IconSet:
"""Hält alle Icons als CTkImage-Instanzen.
Muss nach CTk-Initialisierung erstellt werden.
"""
def __init__(self, size: tuple[int, int] = (18, 18)):
s = size[0]
light = "#3f3f46" # Für hellen Modus
dark = "#e2e8f0" # Für dunklen Modus
accent = "#8b7cf6"
success = "#34d399"
error = "#f87171"
self.folder = make_ctk_image(icon_folder(light, s), icon_folder(dark, s), size)
self.file_add = make_ctk_image(icon_file_add(light, s), icon_file_add(dark, s), size)
self.play = make_ctk_image(icon_play(light, s), icon_play(dark, s), size)
self.settings = make_ctk_image(icon_settings(light, s), icon_settings(dark, s), size)
self.refresh = make_ctk_image(icon_refresh(light, s), icon_refresh(dark, s), size)
self.save = make_ctk_image(icon_save(light, s), icon_save(dark, s), size)
self.browse = make_ctk_image(icon_browse(light, s), icon_browse(dark, s), size)
self.check = make_ctk_image(icon_check(success, s), icon_check(success, s), size)
self.cross = make_ctk_image(icon_cross(error, s), icon_cross(error, s), size)
self.music = make_ctk_image(icon_music(light, s), icon_music(dark, s), size)
self.info = make_ctk_image(icon_info(light, s), icon_info(dark, s), size)
self.download = make_ctk_image(icon_download(light, s), icon_download(dark, s), size)
+88
View File
@@ -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()
+224
View File
@@ -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)
-33
View File
@@ -1,33 +0,0 @@
#!/bin/bash
echo "Starte macOS Installation für fcco-Converter..."
# Ins Root-Verzeichnis wechseln
cd "$(dirname "$0")/.."
# Prüfe Homebrew
if ! command -v brew &> /dev/null; then
echo "Homebrew ist nicht installiert. Installiere Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
# Installiere Abhängigkeiten via Homebrew
echo "Installiere Python und FFmpeg falls nötig..."
brew install python ffmpeg python-tk@3.11
# Virtuelle Umgebung erstellen
if [ ! -d ".venv" ]; then
echo "Erstelle virtuelle Umgebung..."
python3 -m venv .venv
fi
# Pakete installieren
echo "Installiere Python-Pakete..."
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
echo "==================="
echo "Installation abgeschlossen!"
echo "Du kannst das Programm nun mit einem Doppelklick auf 'start.sh' im Hauptordner starten,"
echo "oder im Terminal './start.sh' ausführen."
echo "==================="
-37
View File
@@ -1,37 +0,0 @@
Write-Host "Starte Windows Installation für fcco-Converter..." -ForegroundColor Cyan
# Ins Root-Verzeichnis wechseln
$PSScriptRoot = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition
Set-Location -Path "$PSScriptRoot\.."
Write-Host "Überprüfe FFmpeg..." -ForegroundColor Yellow
if (Get-Command ffmpeg -ErrorAction SilentlyContinue) {
Write-Host "FFmpeg ist bereits installiert." -ForegroundColor Green
} else {
Write-Host "Installiere FFmpeg via winget..." -ForegroundColor Yellow
winget install --id "Gyan.FFmpeg" --accept-package-agreements --accept-source-agreements
}
Write-Host "Überprüfe Python..." -ForegroundColor Yellow
if (-not (Get-Command python -ErrorAction SilentlyContinue)) {
Write-Host "Python ist nicht installiert. Installiere Python via winget..." -ForegroundColor Yellow
winget install --id "Python.Python.3.11" --accept-package-agreements --accept-source-agreements
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
} else {
Write-Host "Python ist bereits installiert." -ForegroundColor Green
}
if (-not (Test-Path ".venv")) {
Write-Host "Erstelle virtuelle Umgebung (.venv)..." -ForegroundColor Yellow
python -m venv .venv
}
Write-Host "Installiere Python-Pakete..." -ForegroundColor Yellow
& .\.venv\Scripts\python.exe -m pip install --upgrade pip
& .\.venv\Scripts\python.exe -m pip install -r requirements.txt
Write-Host "===================" -ForegroundColor Cyan
Write-Host "Installation abgeschlossen!" -ForegroundColor Green
Write-Host "Du kannst das Programm nun mit einem Doppelklick auf 'start.bat' im Hauptordner starten." -ForegroundColor White
Write-Host "===================" -ForegroundColor Cyan
Pause
+20 -446
View File
@@ -1,456 +1,30 @@
import os
import json
import ctypes
import threading
import subprocess
from datetime import datetime
from tkinter import filedialog, messagebox, simpledialog, Toplevel, Button
from tkinter import *
from tkinter.ttk import Progressbar
from concurrent.futures import ThreadPoolExecutor, as_completed
#!/usr/bin/env python3
"""
fcco-Converter V2 Einstiegspunkt
try:
from mutagen.easyid3 import EasyID3
from mutagen.id3 import ID3, APIC
except ImportError:
messagebox.showerror("Fehler", "Die Bibliothek 'mutagen' ist nicht installiert. Bitte installieren Sie sie mit 'pip install mutagen'.")
exit()
Startet die grafische Benutzeroberfläche.
"""
SETTINGS_FILE = "settings.json"
HISTORY_FILE = "history.json"
import sys
import platform
WEEKDAY_SHORT_DE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
DEFAULT_EXPORT_PATHS = {
"Gottesdienst": "gottesdienste",
"Hochzeit": "hochzeiten",
"Weissagung": "weissagungen",
"Jugend": "jugend"
}
def load_settings():
if os.path.exists(SETTINGS_FILE):
with open(SETTINGS_FILE, "r") as f:
return json.load(f)
return {
"export_paths": DEFAULT_EXPORT_PATHS.copy(),
"threads": 0 # 0 = auto, use all available cores
}
def save_settings(settings):
with open(SETTINGS_FILE, "w") as f:
json.dump(settings, f, indent=2)
def save_export_history(entry):
history = []
if os.path.exists(HISTORY_FILE):
with open(HISTORY_FILE, "r") as f:
history = json.load(f)
history.append(entry)
with open(HISTORY_FILE, "w") as f:
json.dump(history, f, indent=2)
# Fix pixelation on high-DPI displays
try:
ctypes.windll.shcore.SetProcessDpiAwareness(2) # Per-monitor DPI awareness
except:
# DPI-Awareness auf Windows setzen (vor dem GUI-Import)
if platform.system() == "Windows":
try:
ctypes.windll.user32.SetProcessDPIAware() # Fallback for older Windows
except:
pass
settings = load_settings()
def set_window_icon(window):
try:
window.iconbitmap("icon.ico")
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(2)
except Exception:
pass
root = Tk()
set_window_icon(root)
root.title("fcco Converter")
root.geometry("800x1200")
Label(root, text="Wähle WAV-Dateien oder Ordner aus:").pack(pady=5)
file_listbox = Listbox(root, width=60)
file_listbox.pack(pady=5)
file_status_listbox = Listbox(root, width=60)
file_status_listbox.pack(pady=5)
progress_bars = [] # List to store per-file progress bars
file_entries = [] # List of dicts: {'full_path': str, 'rel_dir': str}
base_folder = None # Set when folder is selected
is_converting = False # Track conversion status
conversion_running = False # Prevent multiple conversions
def choose_files():
global file_entries, base_folder
paths = filedialog.askopenfilenames(filetypes=[("WAV-Dateien", "*.wav")])
if paths:
base_folder = None
file_entries = [{'full_path': p, 'rel_dir': ''} for p in paths]
update_file_list()
def choose_folder():
global file_entries, base_folder
folder = filedialog.askdirectory()
if folder:
base_folder = folder
file_entries = []
for root_dir, _, files in os.walk(folder):
for f in files:
if f.lower().endswith('.wav'):
full_path = os.path.join(root_dir, f)
rel_path = os.path.relpath(root_dir, folder)
file_entries.append({'full_path': full_path, 'rel_dir': rel_path})
update_file_list()
def update_file_list():
file_listbox.delete(0, END)
file_status_listbox.delete(0, END)
for bar in progress_bars:
bar.destroy()
progress_bars.clear()
for entry in file_entries:
basename = os.path.basename(entry['full_path'])
display_name = os.path.join(entry['rel_dir'], basename) if entry['rel_dir'] else basename
file_listbox.insert(END, display_name)
file_status_listbox.insert(END, f"0% {basename} Wartet...")
progress = Progressbar(root, orient=HORIZONTAL, length=400, mode='determinate')
progress.pack(pady=2)
progress_bars.append(progress)
Button(root, text="Dateien auswählen", command=choose_files).pack(pady=5)
Button(root, text="Ordner auswählen", command=choose_folder).pack(pady=5)
def generate_album_name(veranstaltung=""):
return datetime.now().strftime("%d.%m.%Y") + (f" {veranstaltung}" if veranstaltung else "")
def generate_folder_name(date_part, veranstaltung):
return f"{date_part} {veranstaltung}" if veranstaltung else date_part
def extract_event_from_folder(file_path):
folder_name = os.path.basename(os.path.dirname(file_path))
if len(folder_name) >= 8 and folder_name[:6].isdigit() and folder_name[6:8] in WEEKDAY_SHORT_DE:
return folder_name[:8], folder_name[8:].strip() if len(folder_name) > 8 else ""
return "", ""
def open_settings_window():
win = Toplevel(root)
set_window_icon(win)
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
ctypes.windll.user32.SetProcessDPIAware()
except Exception:
pass
settings["metadata"] = {
"artist": artist_entry.get(),
"cover": cover_entry.get()
}
settings["export_paths"] = {k: v.get() for k, v in entries.items()}
settings["threads"] = threads
save_settings(settings)
messagebox.showinfo("Gespeichert", "Einstellungen wurden gespeichert.")
win.destroy()
Button(win, text="Speichern", command=save_all).pack(pady=10)
def main():
from fcco_converter.gui import App
app = App()
app.mainloop()
Button(root, text="Einstellungen öffnen", command=open_settings_window).pack(pady=5)
def confirm_metadata_and_type():
meta = settings.get("metadata", {}).copy()
if file_entries:
date_part, veranstaltung = extract_event_from_folder(file_entries[0]['full_path'])
if not date_part:
date_part = datetime.now().strftime("%y%m%d") + WEEKDAY_SHORT_DE[datetime.now().weekday()]
else:
date_part = datetime.now().strftime("%y%m%d") + WEEKDAY_SHORT_DE[datetime.now().weekday()]
veranstaltung = ""
meta["album"] = generate_album_name(veranstaltung)
meta["year"] = str(datetime.now().year)
win = Toplevel(root)
set_window_icon(win)
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)
set_window_icon(win)
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()
+80
View File
@@ -0,0 +1,80 @@
import re
from pathlib import Path
file_path = Path("fcco_converter/gui.py")
content = file_path.read_text(encoding="utf-8")
# Helper-Methode hinzufügen
helper = """
def _btn(self, parent, text, command, **kwargs):
btn = ctk.CTkButton(parent, text=text, **kwargs)
if IS_MACOS and command:
# macOS Bugfix: Direkter Bind statt Canvas-Command
btn.bind("<ButtonRelease-1>", lambda e: self.after(10, command))
elif command:
btn.configure(command=command)
return btn
"""
if "def _btn(self" not in content:
content = content.replace("def _preload_ffmpeg(self):", helper.lstrip() + "\n def _preload_ffmpeg(self):")
# Alle ctk.CTkButton(..., command=...) Aufrufe umbauen.
# Das ist per Regex etwas fragil, daher machen wir es manuell für die bekannten Stellen:
replacements = [
(
'self.update_btn = ctk.CTkButton(\n h, text=" Update prüfen", image=self.icons.refresh,\n width=150, height=32,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text2"], font=ctk.CTkFont(size=12),\n command=self._check_updates)',
'self.update_btn = self._btn(\n h, text=" Update prüfen", command=self._check_updates,\n image=self.icons.refresh, width=150, height=32,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text2"], font=ctk.CTkFont(size=12))'
),
(
'ctk.CTkButton(\n h, text="", image=self.icons.settings,\n width=34, height=32,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n command=self._open_settings\n ).grid',
'self._btn(\n h, text="", command=self._open_settings,\n image=self.icons.settings, width=34, height=32,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"]\n ).grid'
),
(
'ctk.CTkButton(\n tb, text=" Dateien", image=self.icons.file_add,\n width=130, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text"], font=ctk.CTkFont(size=13),\n command=self._pick_files\n ).grid',
'self._btn(\n tb, text=" Dateien", command=self._pick_files,\n image=self.icons.file_add, width=130, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text"], font=ctk.CTkFont(size=13)\n ).grid'
),
(
'ctk.CTkButton(\n tb, text=" Ordner", image=self.icons.folder,\n width=130, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text"], font=ctk.CTkFont(size=13),\n command=self._pick_folder\n ).grid',
'self._btn(\n tb, text=" Ordner", command=self._pick_folder,\n image=self.icons.folder, width=130, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text"], font=ctk.CTkFont(size=13)\n ).grid'
),
(
'self.conv_btn = ctk.CTkButton(\n tb, text=" Konvertieren", image=self.icons.play,\n width=160, height=36,\n fg_color=T["success"], hover_color=T["success_h"],\n text_color="#ffffff", font=ctk.CTkFont(size=14, weight="bold"),\n command=self._convert)',
'self.conv_btn = self._btn(\n tb, text=" Konvertieren", command=self._convert,\n image=self.icons.play, width=160, height=36,\n fg_color=T["success"], hover_color=T["success_h"],\n text_color="#ffffff", font=ctk.CTkFont(size=14, weight="bold"))'
),
(
'ctk.CTkButton(cover_fr, text="", image=self.icons.browse,\n width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"], command=_bc\n ).grid',
'self._btn(cover_fr, text="", command=_bc, image=self.icons.browse,\n width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"]\n ).grid'
),
(
'ctk.CTkButton(bf, text="Abbrechen", width=110, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text2"], command=d.destroy\n ).pack',
'self._btn(bf, text="Abbrechen", command=d.destroy, width=110, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text2"]\n ).pack'
),
(
'ctk.CTkButton(bf, text=" Konvertieren starten",\n image=self.icons.play, width=180, height=34,\n fg_color=T["accent"], hover_color=T["accent_h"],\n text_color="#ffffff", font=ctk.CTkFont(weight="bold"),\n command=_ok\n ).pack',
'self._btn(bf, text=" Konvertieren starten", command=_ok,\n image=self.icons.play, width=180, height=34,\n fg_color=T["accent"], hover_color=T["accent_h"],\n text_color="#ffffff", font=ctk.CTkFont(weight="bold")\n ).pack'
),
(
'ctk.CTkButton(mcf, text="", image=self.icons.browse,\n width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"], command=_bc2\n ).pack',
'self._btn(mcf, text="", command=_bc2, image=self.icons.browse,\n width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"]\n ).pack'
),
(
'ctk.CTkButton(rf, text="", image=self.icons.browse,\n width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"], command=_br\n ).pack',
'self._btn(rf, text="", command=_br, image=self.icons.browse,\n width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"]\n ).pack'
),
(
'ctk.CTkButton(bf, text="Abbrechen", width=110, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text2"], command=w.destroy\n ).pack',
'self._btn(bf, text="Abbrechen", command=w.destroy, width=110, height=34,\n fg_color=T["btn2"], hover_color=T["btn2_h"],\n border_width=1, border_color=T["border"],\n text_color=T["text2"]\n ).pack'
),
(
'ctk.CTkButton(bf, text=" Speichern", image=self.icons.save,\n width=140, height=34,\n fg_color=T["accent"], hover_color=T["accent_h"],\n text_color="#ffffff", font=ctk.CTkFont(weight="bold"),\n command=lambda: self._save_s(wg, w)\n ).pack',
'self._btn(bf, text=" Speichern", command=lambda: self._save_s(wg, w),\n image=self.icons.save, width=140, height=34,\n fg_color=T["accent"], hover_color=T["accent_h"],\n text_color="#ffffff", font=ctk.CTkFont(weight="bold")\n ).pack'
)
]
for old, new in replacements:
content = content.replace(old, new)
file_path.write_text(content, encoding="utf-8")
print("Patch applied.")
+6 -2
View File
@@ -1,2 +1,6 @@
pydub
mutagen
customtkinter>=5.2.0
mutagen>=1.47.0
static-ffmpeg>=2.7
requests>=2.31.0
Pillow>=10.0.0
pyinstaller>=6.0
-3
View File
@@ -1,3 +0,0 @@
@echo off
start /min python main.py
exit
-11
View File
@@ -1,11 +0,0 @@
#!/bin/bash
cd "$(dirname "$0")"
if [ ! -d ".venv" ]; then
echo "Die virtuelle Umgebung fehlt."
echo "Bitte führe zuerst die Installation (install/install_macos.sh) aus!"
exit 1
fi
source .venv/bin/activate
python3 main.py
+23
View File
@@ -0,0 +1,23 @@
import customtkinter as ctk
app = ctk.CTk()
app.geometry("400x300")
def on_click():
print("Button clicked via command!")
def on_bind(e):
print("Button clicked via bind!")
b1 = ctk.CTkButton(app, text="Normal Command", command=on_click)
b1.pack(pady=20)
b2 = ctk.CTkButton(app, text="Bind Button-1")
b2.bind("<Button-1>", on_bind)
b2.pack(pady=20)
b3 = ctk.CTkButton(app, text="Bind ButtonRelease-1")
b3.bind("<ButtonRelease-1>", on_bind)
b3.pack(pady=20)
app.mainloop()