Files
fcco-Converter/main.py
T

450 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("<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)
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()