Compare commits
12
Commits
6f902eda8e
...
v1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb912d66a4 | ||
|
|
a8d9a5ec1a | ||
|
|
65a04781f9 | ||
|
|
602afa7d42 | ||
|
|
0bd37cdd6f | ||
|
|
6dece1410c | ||
|
|
1d474182e2 | ||
|
|
d795d45b6f | ||
|
|
ca0494c91b | ||
|
|
e31794462c | ||
|
|
1ae4cff45b | ||
|
|
179d4bf127 |
@@ -0,0 +1,67 @@
|
|||||||
|
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
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
.DS_Store
|
||||||
|
Cover/*
|
||||||
|
App
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# PyInstaller Build-Artefakte
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Laufzeit-Dateien (werden lokal generiert)
|
||||||
|
history.json
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 🎵 WAV zu MP3 Konverter
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Installation
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 🍏 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
|
||||||
|
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.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Anwendung starten
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Projektstruktur
|
||||||
|
|
||||||
|
📦 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ℹ️ 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.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 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',
|
||||||
|
)
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/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 "==================="
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
SETTINGS_FILE = "settings.json"
|
||||||
|
HISTORY_FILE = "history.json"
|
||||||
|
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
ctypes.windll.user32.SetProcessDPIAware() # Fallback for older Windows
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
def set_window_icon(window):
|
||||||
|
try:
|
||||||
|
window.iconbitmap("icon.ico")
|
||||||
|
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
|
||||||
|
|
||||||
|
settings["metadata"] = {
|
||||||
|
"artist": artist_entry.get(),
|
||||||
|
"cover": cover_entry.get()
|
||||||
|
}
|
||||||
|
settings["export_paths"] = {k: v.get() for k, v in entries.items()}
|
||||||
|
settings["threads"] = threads
|
||||||
|
save_settings(settings)
|
||||||
|
messagebox.showinfo("Gespeichert", "Einstellungen wurden gespeichert.")
|
||||||
|
win.destroy()
|
||||||
|
|
||||||
|
Button(win, text="Speichern", command=save_all).pack(pady=10)
|
||||||
|
|
||||||
|
Button(root, text="Einstellungen öffnen", command=open_settings_window).pack(pady=5)
|
||||||
|
|
||||||
|
def confirm_metadata_and_type():
|
||||||
|
meta = settings.get("metadata", {}).copy()
|
||||||
|
if file_entries:
|
||||||
|
date_part, veranstaltung = extract_event_from_folder(file_entries[0]['full_path'])
|
||||||
|
if not date_part:
|
||||||
|
date_part = datetime.now().strftime("%y%m%d") + WEEKDAY_SHORT_DE[datetime.now().weekday()]
|
||||||
|
else:
|
||||||
|
date_part = datetime.now().strftime("%y%m%d") + WEEKDAY_SHORT_DE[datetime.now().weekday()]
|
||||||
|
veranstaltung = ""
|
||||||
|
meta["album"] = generate_album_name(veranstaltung)
|
||||||
|
meta["year"] = str(datetime.now().year)
|
||||||
|
|
||||||
|
win = Toplevel(root)
|
||||||
|
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()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pydub
|
||||||
|
mutagen
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"export_paths": {
|
||||||
|
"Gottesdienst": "D:\\Tonaufnahmen\\3MP3-192kbit Gottesdienste\\2026",
|
||||||
|
"Hochzeit": "D:\\Tonaufnahmen\\4MP3-192kbit Hochzeiten",
|
||||||
|
"Weissagung": "D:\\Tonaufnahmen\\5Sonstiges\\Weissagungen",
|
||||||
|
"Jugend": "D:\\Tonaufnahmen\\3MP3-192kbit Gottesdienste\\2026"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"artist": "FECG Rottweil-Neukirch",
|
||||||
|
"cover": "D:\\Portable Software\\fcco_Converter\\Cover\\cover_fecg_rottweil.jpg"
|
||||||
|
},
|
||||||
|
"last_export_dir": "D:\\Tonaufnahmen\\3MP3-192kbit Gottesdienste\\2026\\260406Mo Ostermontag",
|
||||||
|
"threads": 0
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user