9 Commits
8 changed files with 206 additions and 108 deletions
+25 -47
View File
@@ -1,6 +1,6 @@
# fcco Converter V2
WAV-zu-MP3-Konverter mit grafischer Benutzeroberfläche. Unterstützt automatische Metadaten-Erstellung (Künstler, Album, Titel, Tracknummer), Cover-Bilder und alphabetische Sortierung. Läuft auf **Windows** und **macOS**.
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.
---
@@ -13,16 +13,33 @@ WAV-zu-MP3-Konverter mit grafischer Benutzeroberfläche. Unterstützt automatisc
- Einstellungen werden dauerhaft gespeichert
- Fortschrittsanzeige pro Datei und gesamt
- Automatisches Update direkt aus der App
- Moderne, dunkle Benutzeroberfläche (CustomTkinter)
- Moderne, dunkle Benutzeroberfläche
---
## Automatische Titel-Extrahierung (Neu in V2.0.2)
Das Programm erkennt Dateinamen im typischen fcco-Format und erstellt daraus automatisch saubere Songtitel (ID3-Tags).
Das Muster wird nach dem Unterstrich-Format extrahiert. Hier sind Beispiele, wie aus Dateinamen automatisch MP3-Titel gebildet werden:
- 260719So_N_10_Predigt_D Vorname Nachname.wav -> "Predigt Vorname Nachname"
- 260719So_N_07_Vorwort_D Vorname Nachname.wav -> "Vorwort Vorname Nachname"
- 260719So_N_09_Gedicht_D Vorname Nachname.wav -> "Gedicht Vorname Nachname"
- 260719So_N_10_Predigt_D.wav -> "Predigt D"
- 260719So_N_10_Predigt_D Gast.wav -> "Predigt Gast"
- 260719So_N_06_Lied_R О пройденном я подвожу итог.wav -> "О пройденном я подвожу итог"
- 260719So_N_04_Lied_D Zum blauen Himmel.wav -> "Zum blauen Himmel"
- 260719So_N_04_Chor_D Zum blauen Himmel.wav -> "Zum blauen Himmel (Chor)"
- 260719So_N_04_Orchester_D Zum blauen Himmel.wav -> "Zum blauen Himmel (Orchester)"
---
## Installation (Endnutzer)
Lade die neueste Version von der [Releases-Seite](https://git.joelunger.de/public/fcco-Converter/releases) herunter:
Lade die neueste Version von der Releases-Seite (Gitea) herunter:
- **Windows**: `fcco-Converter-windows.zip` entpacken und `fcco-Converter.exe` starten.
- **macOS**: `fcco-Converter-macos.zip` entpacken und `fcco-Converter` starten.
- Windows: fcco-Converter-windows.zip entpacken und fcco-Converter.exe starten.
- macOS: fcco-Converter-macos.zip entpacken und fcco-Converter starten.
FFmpeg wird automatisch beim ersten Start heruntergeladen. Keine weitere Installation nötig.
@@ -30,66 +47,27 @@ FFmpeg wird automatisch beim ersten Start heruntergeladen. Keine weitere Install
## Updates
In der App auf **"Update prüfen"** klicken. Wenn eine neue Version verfügbar ist, wird sie direkt heruntergeladen und installiert. Danach muss die App einmal neu gestartet werden.
In der App auf "Update prüfen" klicken. Wenn eine neue Version verfügbar ist, wird sie direkt heruntergeladen und installiert. Danach muss die App einmal neu gestartet werden.
---
## Entwicklung
### Voraussetzungen
- Python 3.11 oder neuer
- Virtuelle Umgebung empfohlen
### Setup
```bash
python -m venv .venv
source .venv/bin/activate # macOS / Linux
# .venv\Scripts\activate # Windows
pip install -r requirements.txt
```
### Starten
```bash
python main.py
```
### Build (Standalone-Executable)
```bash
### Build (Standalone-Executable & ZIP-Erstellung)
python build.py
```
Die fertige Datei liegt anschließend im `dist/`-Verzeichnis.
---
## Projektstruktur
```
fcco-Converter/
├── main.py # Einstiegspunkt
├── build.py # Build-Skript (PyInstaller)
├── requirements.txt # Abhängigkeiten
├── icon.ico # Programm-Icon
├── fcco_converter/
│ ├── __init__.py
│ ├── config.py # Einstellungen & Konfiguration
│ ├── converter.py # FFmpeg-Konvertierung
│ ├── metadata.py # ID3-Tags & Cover
│ ├── updater.py # Auto-Update von Gitea Releases
│ └── gui.py # CustomTkinter GUI
└── README.md
```
---
## Hinweise
- Nur `.wav`-Dateien werden als Eingabe akzeptiert.
- Cover-Bilder müssen `.jpg`, `.jpeg` oder `.png` sein.
- Export erfolgt im `.mp3`-Format (192 kbit/s).
- Einstellungen werden in `~/.config/fcco-Converter/` (Linux), `~/Library/Application Support/fcco-Converter/` (macOS) bzw. `%APPDATA%/fcco-Converter/` (Windows) gespeichert.
Die fertige App und die Release-ZIP-Datei liegen anschließend im dist/ Verzeichnis.
+47 -7
View File
@@ -12,28 +12,41 @@ import sys
from pathlib import Path
import shutil
def build():
system = platform.system()
project_dir = Path(__file__).parent
dist_dir = project_dir / "dist"
# Alte App löschen
app_name = "fcco-Converter"
if system == "Darwin":
old_app = dist_dir / f"{app_name}.app"
else:
old_app = dist_dir / f"{app_name}.exe"
if old_app.exists():
if old_app.is_dir():
shutil.rmtree(old_app)
else:
old_app.unlink()
# Basis-Argumente für PyInstaller
args = [
sys.executable,
"-m", "PyInstaller",
"--onefile",
"--windowed",
"--name", "fcco-Converter",
"--name", app_name,
"--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)])
args.extend(["--icon", str(icon_path)])
# Versteckte Imports (die PyInstaller evtl. nicht automatisch findet)
hidden_imports = [
@@ -66,8 +79,35 @@ def build():
result = subprocess.run(args, cwd=str(project_dir))
if result.returncode == 0:
# Kopiere config.json in den dist Ordner, damit die App sie direkt findet
config_src = project_dir / "config.json"
if config_src.exists():
shutil.copy(config_src, dist_dir / "config.json")
# ZIP-Archiv für das Release erstellen
import tempfile
sys_name = "macos" if system == "Darwin" else system.lower()
zip_name = f"fcco-Converter-{sys_name}"
zip_path = dist_dir / zip_name
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
# App/Exe ins Temp-Verzeichnis kopieren
if system == "Darwin":
shutil.copytree(dist_dir / f"{app_name}.app", tmp / f"{app_name}.app")
else:
shutil.copy(dist_dir / f"{app_name}.exe", tmp / f"{app_name}.exe")
# config.json mit in die ZIP packen
if config_src.exists():
shutil.copy(config_src, tmp / "config.json")
shutil.make_archive(str(zip_path), 'zip', str(tmp))
print()
print(f"Build erfolgreich! Ausgabe in: {project_dir / 'dist'}")
print(f"Build erfolgreich! Ausgabe in: {dist_dir}")
print(f"ZIP für Release: {zip_path}.zip")
else:
print()
print(f"Build fehlgeschlagen (Code {result.returncode}).")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"app_name": "fcco Converter",
"app_version": "2.0.0",
"app_version": "2.0.2",
"repo_owner": "public",
"repo_name": "fcco-Converter",
"gitea_api_base": "https://git.joelunger.de/api/v1"
+6 -1
View File
@@ -13,7 +13,7 @@ from pathlib import Path
# ---- Globale App-Informationen (aus config.json) ----
APP_INFO = {
"app_name": "fcco Converter",
"app_version": "2.0.0",
"app_version": "2.0.2",
"repo_owner": "public",
"repo_name": "fcco-Converter",
"gitea_api_base": "https://git.joelunger.de/api/v1"
@@ -23,7 +23,12 @@ def _load_app_info():
try:
# Finde config.json relativ zum Skript oder im PyInstaller Bundle
if getattr(sys, "frozen", False):
# Suche ausgehend von der Executable aufwärts nach config.json
base_dir = Path(sys.executable).parent
while base_dir.name and str(base_dir) != base_dir.root:
if (base_dir / "config.json").exists():
break
base_dir = base_dir.parent
else:
base_dir = Path(__file__).parent.parent
+24 -3
View File
@@ -115,6 +115,9 @@ def _build_ffmpeg_cmd(
else:
cmd.append("-n")
# Keine Video/Bilder-Streams übernehmen (verhindert Konflikte mit eigenen Covern)
cmd.append("-vn")
cmd.append(str(output_path))
return cmd
@@ -190,6 +193,8 @@ def convert_file_entry(
audio_settings: dict | None = None,
threads: int = 0,
progress_callback: Callable[[int, int, str], None] | None = None,
cover_data: bytes | None = None,
cover_mime: str = "image/jpeg",
) -> tuple[int, str, str | None]:
"""Konvertiert eine Datei und schreibt die Metadaten.
@@ -200,7 +205,8 @@ def convert_file_entry(
audio_settings = {"output_format": "mp3", "bitrate": "192", "encoding_mode": "CBR"}
file_path = Path(entry["full_path"])
title = file_path.stem
from .metadata import extract_title_from_filename
title = extract_title_from_filename(file_path.stem)
output_format = audio_settings.get("output_format", "mp3")
try:
@@ -227,7 +233,6 @@ def convert_file_entry(
# 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,
@@ -235,7 +240,8 @@ def convert_file_entry(
album=meta.get("album", ""),
track_number=index + 1,
year=meta.get("year", ""),
cover_path=cover_path,
cover_data=cover_data,
cover_mime=cover_mime,
)
return (index, f"{title}.{output_format}", None)
@@ -266,6 +272,19 @@ def run_batch_conversion(
successful_files = []
errors = []
# Cover-Bild einmalig in den Arbeitsspeicher laden, um Dateisperren (Locking)
# bei parallelem Zugriff durch Windows Defender/Antivirus zu vermeiden
cover_data = None
cover_mime = "image/jpeg"
cover_path_str = meta.get("cover")
if cover_path_str:
cp = Path(cover_path_str)
if cp.exists():
if cp.suffix.lower() == ".png":
cover_mime = "image/png"
with open(cp, "rb") as f:
cover_data = f.read()
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
@@ -280,6 +299,8 @@ def run_batch_conversion(
audio_settings=audio_settings,
threads=0,
progress_callback=progress_callback,
cover_data=cover_data,
cover_mime=cover_mime,
): idx
for idx, entry in enumerate(entries)
}
+68 -24
View File
@@ -4,6 +4,7 @@ fcco-Converter V2 Metadaten-Modul
Hilfsfunktionen für ID3-Tags, Cover-Bilder und Albumname-Generierung.
"""
import re
from datetime import datetime
from pathlib import Path
@@ -51,6 +52,49 @@ def extract_event_from_folder(file_path: Path) -> tuple[str, str]:
return "", ""
def extract_title_from_filename(filename: str) -> str:
"""Extrahiert einen sauberen Titel aus dem komplexen Dateinamen-Format.
Format-Erwartung: [Prefix]_[Track]_[Type]_[Lang][ Remainder]
Beispiele:
260719So_N_10_Predigt_D Vorname Nachname -> "Predigt Vorname Nachname"
260719So_N_04_Lied_D Zum blauen Himmel -> "Zum blauen Himmel"
"""
# Regulärer Ausdruck, um die Komponenten zu extrahieren
match = re.match(r"^.*?_\d+_(?P<type>[a-zA-Z]+)_(?P<lang>[a-zA-Z]+)(?: (?P<remainder>.*))?$", filename)
if not match:
return filename
typ = match.group("type")
lang = match.group("lang")
remainder = match.group("remainder") or ""
typ_lower = typ.lower()
if typ_lower in ["predigt", "vorwort", "gedicht"]:
if remainder:
return f"{typ} {remainder}"
return f"{typ} {lang}"
elif typ_lower == "lied":
if remainder:
return remainder
return f"{typ} {lang}"
elif typ_lower in ["chor", "orchester"]:
if remainder:
return f"{remainder} ({typ})"
return f"{typ} {lang}"
else:
# Fallback
if remainder:
return f"{typ} {remainder}"
return f"{typ} {lang}"
from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TRCK, TDRC
def apply_id3_tags(
mp3_path: Path,
title: str,
@@ -58,31 +102,31 @@ def apply_id3_tags(
album: str,
track_number: int,
year: str,
cover_path: Path | None = None,
cover_data: bytes | None = None,
cover_mime: str = "image/jpeg",
) -> 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()
try:
tags = ID3(str(mp3_path))
except Exception:
tags = ID3()
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(),
)
tags.add(TIT2(encoding=3, text=title))
tags.add(TPE1(encoding=3, text=artist or "Unbekannt"))
tags.add(TALB(encoding=3, text=album or "Unbekanntes Album"))
tags.add(TRCK(encoding=3, text=str(track_number)))
if year:
tags.add(TDRC(encoding=3, text=year))
if cover_data:
tags.add(
APIC(
encoding=3,
mime=cover_mime,
type=3,
desc="Cover",
data=cover_data,
)
audio_id3.save()
)
tags.save(str(mp3_path), v2_version=3)
+30 -20
View File
@@ -39,36 +39,46 @@ def _fetch_json(url: str) -> list | dict:
return json.loads(resp.read().decode("utf-8"))
def _parse_version(v: str) -> list[int]:
parts = []
for p in v.lstrip("vV").split("."):
try:
parts.append(int(p))
except ValueError:
parts.append(0)
while len(parts) < 3:
parts.append(0)
return parts
def check_for_updates() -> dict | None:
"""Prüft, ob ein neues Release verfügbar ist.
"""Prüft, ob auf Gitea ein neues Release (nach SemVer) vorliegt.
Returns:
Dict mit Release-Infos (tag_name, name, body, assets) oder None.
Das Release-Dict (von Gitea) wenn ein Update verfügbar ist, sonst None.
"""
try:
releases = _fetch_json(RELEASES_URL)
if not releases:
req = Request(RELEASES_URL, headers={'User-Agent': 'fcco-Updater/1.0'})
with urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode('utf-8'))
if not data:
return None
# Neuestes (nicht als Draft/Prerelease markiertes) Release
for release in releases:
if release.get("draft") or release.get("prerelease"):
continue
# Gitea gibt Releases sortiert zurück, 0 = neuestes
release_info = data[0]
tag_name = release_info.get("tag_name", "")
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
# Entferne eventuelles "v" Präfix
latest_version = tag_name.lstrip("vV")
# Intelligenter Versions-Vergleich
if _parse_version(latest_version) > _parse_version(CURRENT_VERSION):
return release_info
return None
except (URLError, json.JSONDecodeError, OSError):
except Exception as e:
print(f"Update Check Error: {e}")
return None
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB