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