9 Commits
8 changed files with 206 additions and 108 deletions
+25 -47
View File
@@ -1,6 +1,6 @@
# fcco Converter V2 # 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 - Einstellungen werden dauerhaft gespeichert
- Fortschrittsanzeige pro Datei und gesamt - Fortschrittsanzeige pro Datei und gesamt
- Automatisches Update direkt aus der App - 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) ## 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. - Windows: fcco-Converter-windows.zip entpacken und fcco-Converter.exe starten.
- **macOS**: `fcco-Converter-macos.zip` entpacken und `fcco-Converter` starten. - macOS: fcco-Converter-macos.zip entpacken und fcco-Converter starten.
FFmpeg wird automatisch beim ersten Start heruntergeladen. Keine weitere Installation nötig. 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 ## 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 ## Entwicklung
### Voraussetzungen ### Voraussetzungen
- Python 3.11 oder neuer - Python 3.11 oder neuer
- Virtuelle Umgebung empfohlen - Virtuelle Umgebung empfohlen
### Setup ### Setup
```bash
python -m venv .venv python -m venv .venv
source .venv/bin/activate # macOS / Linux source .venv/bin/activate # macOS / Linux
# .venv\Scripts\activate # Windows # .venv\Scripts\activate # Windows
pip install -r requirements.txt pip install -r requirements.txt
```
### Starten ### Starten
```bash
python main.py python main.py
```
### Build (Standalone-Executable) ### Build (Standalone-Executable & ZIP-Erstellung)
```bash
python build.py python build.py
```
Die fertige Datei liegt anschließend im `dist/`-Verzeichnis. Die fertige App und die Release-ZIP-Datei liegen 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.
+46 -6
View File
@@ -12,27 +12,40 @@ import sys
from pathlib import Path from pathlib import Path
import shutil
def build(): def build():
system = platform.system() system = platform.system()
project_dir = Path(__file__).parent 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 # Basis-Argumente für PyInstaller
args = [ args = [
sys.executable, sys.executable,
"-m", "PyInstaller", "-m", "PyInstaller",
"--onefile", "--onefile",
"--windowed", "--windowed",
"--name", "fcco-Converter", "--name", app_name,
"--clean", "--clean",
] ]
# Icon hinzufügen (wenn vorhanden) # Icon hinzufügen (wenn vorhanden)
icon_path = project_dir / "icon.ico" icon_path = project_dir / "icon.ico"
if icon_path.exists(): 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) # Versteckte Imports (die PyInstaller evtl. nicht automatisch findet)
@@ -66,8 +79,35 @@ def build():
result = subprocess.run(args, cwd=str(project_dir)) result = subprocess.run(args, cwd=str(project_dir))
if result.returncode == 0: 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()
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: else:
print() print()
print(f"Build fehlgeschlagen (Code {result.returncode}).") print(f"Build fehlgeschlagen (Code {result.returncode}).")
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"app_name": "fcco Converter", "app_name": "fcco Converter",
"app_version": "2.0.0", "app_version": "2.0.2",
"repo_owner": "public", "repo_owner": "public",
"repo_name": "fcco-Converter", "repo_name": "fcco-Converter",
"gitea_api_base": "https://git.joelunger.de/api/v1" "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) ---- # ---- Globale App-Informationen (aus config.json) ----
APP_INFO = { APP_INFO = {
"app_name": "fcco Converter", "app_name": "fcco Converter",
"app_version": "2.0.0", "app_version": "2.0.2",
"repo_owner": "public", "repo_owner": "public",
"repo_name": "fcco-Converter", "repo_name": "fcco-Converter",
"gitea_api_base": "https://git.joelunger.de/api/v1" "gitea_api_base": "https://git.joelunger.de/api/v1"
@@ -23,7 +23,12 @@ def _load_app_info():
try: try:
# Finde config.json relativ zum Skript oder im PyInstaller Bundle # Finde config.json relativ zum Skript oder im PyInstaller Bundle
if getattr(sys, "frozen", False): if getattr(sys, "frozen", False):
# Suche ausgehend von der Executable aufwärts nach config.json
base_dir = Path(sys.executable).parent 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: else:
base_dir = Path(__file__).parent.parent base_dir = Path(__file__).parent.parent
+24 -3
View File
@@ -115,6 +115,9 @@ def _build_ffmpeg_cmd(
else: else:
cmd.append("-n") cmd.append("-n")
# Keine Video/Bilder-Streams übernehmen (verhindert Konflikte mit eigenen Covern)
cmd.append("-vn")
cmd.append(str(output_path)) cmd.append(str(output_path))
return cmd return cmd
@@ -190,6 +193,8 @@ def convert_file_entry(
audio_settings: dict | None = None, audio_settings: dict | None = None,
threads: int = 0, threads: int = 0,
progress_callback: Callable[[int, int, str], None] | None = None, progress_callback: Callable[[int, int, str], None] | None = None,
cover_data: bytes | None = None,
cover_mime: str = "image/jpeg",
) -> tuple[int, str, str | None]: ) -> tuple[int, str, str | None]:
"""Konvertiert eine Datei und schreibt die Metadaten. """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"} audio_settings = {"output_format": "mp3", "bitrate": "192", "encoding_mode": "CBR"}
file_path = Path(entry["full_path"]) 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") output_format = audio_settings.get("output_format", "mp3")
try: try:
@@ -227,7 +233,6 @@ def convert_file_entry(
# ID3-Metadaten nur für MP3 schreiben # ID3-Metadaten nur für MP3 schreiben
if output_format == "mp3": if output_format == "mp3":
cover_path = Path(meta["cover"]) if meta.get("cover") else None
apply_id3_tags( apply_id3_tags(
mp3_path=output_file, mp3_path=output_file,
title=title, title=title,
@@ -235,7 +240,8 @@ def convert_file_entry(
album=meta.get("album", ""), album=meta.get("album", ""),
track_number=index + 1, track_number=index + 1,
year=meta.get("year", ""), year=meta.get("year", ""),
cover_path=cover_path, cover_data=cover_data,
cover_mime=cover_mime,
) )
return (index, f"{title}.{output_format}", None) return (index, f"{title}.{output_format}", None)
@@ -266,6 +272,19 @@ def run_batch_conversion(
successful_files = [] successful_files = []
errors = [] 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: with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = { futures = {
executor.submit( executor.submit(
@@ -280,6 +299,8 @@ def run_batch_conversion(
audio_settings=audio_settings, audio_settings=audio_settings,
threads=0, threads=0,
progress_callback=progress_callback, progress_callback=progress_callback,
cover_data=cover_data,
cover_mime=cover_mime,
): idx ): idx
for idx, entry in enumerate(entries) for idx, entry in enumerate(entries)
} }
+63 -19
View File
@@ -4,6 +4,7 @@ fcco-Converter V2 Metadaten-Modul
Hilfsfunktionen für ID3-Tags, Cover-Bilder und Albumname-Generierung. Hilfsfunktionen für ID3-Tags, Cover-Bilder und Albumname-Generierung.
""" """
import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -51,6 +52,49 @@ def extract_event_from_folder(file_path: Path) -> tuple[str, str]:
return "", "" 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( def apply_id3_tags(
mp3_path: Path, mp3_path: Path,
title: str, title: str,
@@ -58,31 +102,31 @@ def apply_id3_tags(
album: str, album: str,
track_number: int, track_number: int,
year: str, year: str,
cover_path: Path | None = None, cover_data: bytes | None = None,
cover_mime: str = "image/jpeg",
) -> None: ) -> None:
"""Schreibt ID3-Metadaten und optional ein Cover in die MP3-Datei.""" """Schreibt ID3-Metadaten und optional ein Cover in die MP3-Datei."""
audiofile = EasyID3(str(mp3_path)) try:
audiofile["title"] = title tags = ID3(str(mp3_path))
audiofile["artist"] = artist or "Unbekannt" except Exception:
audiofile["album"] = album or "Unbekanntes Album" tags = ID3()
audiofile["tracknumber"] = str(track_number)
audiofile["date"] = year
audiofile.save()
if cover_path and cover_path.exists(): tags.add(TIT2(encoding=3, text=title))
audio_id3 = ID3(str(mp3_path)) tags.add(TPE1(encoding=3, text=artist or "Unbekannt"))
mime = "image/jpeg" tags.add(TALB(encoding=3, text=album or "Unbekanntes Album"))
suffix = cover_path.suffix.lower() tags.add(TRCK(encoding=3, text=str(track_number)))
if suffix == ".png": if year:
mime = "image/png" tags.add(TDRC(encoding=3, text=year))
with open(cover_path, "rb") as img:
audio_id3.add( if cover_data:
tags.add(
APIC( APIC(
encoding=3, encoding=3,
mime=mime, mime=cover_mime,
type=3, type=3,
desc="Cover", desc="Cover",
data=img.read(), 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")) 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: 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: 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: try:
releases = _fetch_json(RELEASES_URL) req = Request(RELEASES_URL, headers={'User-Agent': 'fcco-Updater/1.0'})
if not releases: with urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode('utf-8'))
if not data:
return None return None
# Neuestes (nicht als Draft/Prerelease markiertes) Release # Gitea gibt Releases sortiert zurück, 0 = neuestes
for release in releases: release_info = data[0]
if release.get("draft") or release.get("prerelease"): tag_name = release_info.get("tag_name", "")
continue
tag = release.get("tag_name", "").lstrip("v") # Entferne eventuelles "v" Präfix
if tag and tag != CURRENT_VERSION: latest_version = tag_name.lstrip("vV")
# Einfacher Versionsvergleich
try: # Intelligenter Versions-Vergleich
remote_parts = [int(x) for x in tag.split(".")] if _parse_version(latest_version) > _parse_version(CURRENT_VERSION):
local_parts = [int(x) for x in CURRENT_VERSION.split(".")] return release_info
if remote_parts > local_parts:
return release
except ValueError:
continue
break # Nur das neueste Release prüfen
return None return None
except (URLError, json.JSONDecodeError, OSError): except Exception as e:
print(f"Update Check Error: {e}")
return None return None
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB