diff --git a/.DS_Store b/.DS_Store index db3e1fb..f33d6ef 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/main.py b/main.py index e69de29..cdfe9a4 100644 --- a/main.py +++ b/main.py @@ -0,0 +1,450 @@ +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() + +root = Tk() +root.iconbitmap("icon.ico") +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) + win.iconbitmap("icon.ico") + 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) + win.iconbitmap("icon.ico") + 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) + win.iconbitmap("icon.ico") + 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() \ No newline at end of file diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..96616b7 --- /dev/null +++ b/start.bat @@ -0,0 +1,6 @@ +@echo off +REM Stelle sicher, dass Python 3.11 in deinem PATH ist oder gib den vollständigen Pfad an. +python3.11 main.py + +REM Optional: Das Fenster bleibt offen, damit du eventuelle Fehlermeldungen sehen kannst. +pause