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("", 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()