Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c486339f8d | ||
|
|
87b5ba91b1 | ||
|
|
cb13fd8d2b | ||
|
|
eb222dd2ca | ||
|
|
790cdc9911 | ||
|
|
46429663d9 | ||
|
|
6f8074ff43 | ||
|
|
240779052f | ||
|
|
c2d050ed71 |
@@ -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.
|
||||
@@ -12,27 +12,40 @@ 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)])
|
||||
|
||||
# Versteckte Imports (die PyInstaller evtl. nicht automatisch findet)
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+63
-19
@@ -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(
|
||||
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=mime,
|
||||
mime=cover_mime,
|
||||
type=3,
|
||||
desc="Cover",
|
||||
data=img.read(),
|
||||
data=cover_data,
|
||||
)
|
||||
)
|
||||
audio_id3.save()
|
||||
|
||||
tags.save(str(mp3_path), v2_version=3)
|
||||
|
||||
+30
-20
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user