""" Surovy's Music Recorder – Records music from Spotify, Apple Music, Tidal, Amazon Music, Deezer, YouTube Music, TuneIn, SoundCloud, or any system audio source. Desktop apps are embedded directly. Each song is saved as a numbered MP3 in a playlist folder. """ import customtkinter as ctk import tkinter as tk from tkinter import filedialog import ctypes from ctypes import wintypes import psutil import subprocess import threading import time import os import sys import traceback import datetime import re import json from PIL import Image import mutagen from audio import AudioRecorder # ── Win32 constants ──────────────────────────────────────────── user32 = ctypes.windll.user32 kernel32 = ctypes.windll.kernel32 GWL_STYLE = -16 WS_CHILD = 0x40000000 WS_CAPTION = 0x00C00000 WS_THICKFRAME = 0x00040000 WS_POPUP = 0x80000000 SWP_FRAMECHANGED = 0x0020 SWP_NOMOVE = 0x0002 SWP_NOSIZE = 0x0001 SWP_NOZORDER = 0x0004 ES_CONTINUOUS = 0x80000000 ES_SYSTEM_REQUIRED = 0x00000001 ES_AWAYMODE_REQUIRED = 0x00000040 VK_MEDIA_NEXT_TRACK = 0xB0 VK_MEDIA_PREV_TRACK = 0xB1 KEYEVENTF_KEYUP = 0x0002 WM_SETICON = 0x0080 ICON_SMALL = 0 ICON_BIG = 1 IMAGE_ICON = 1 LR_LOADFROMFILE = 0x0010 LR_DEFAULTSIZE = 0x0040 WM_MOUSEWHEEL = 0x020A WHEEL_DELTA = 120 # ── Colors ───────────────────────────────────────────────────── BG = "#0b0b0b" CARD = "#151515" BORDER = "#1e1e1e" GREEN = "#7C9A3C" GREEN_H = "#90AE4A" RED = "#e74c3c" RED_H = "#ff6b6b" ORANGE = "#f59e0b" TXT = "#ffffff" TXT2 = "#b3b3b3" DIM = "#555555" PANEL_W = 330 MIN_SONG_DURATION = 30 # Songs kuerzer als 30s gelten als unvollstaendig FILENAME_FORMATS = [ ("{nr} - {artist} - {titel}", "Nr - Artist - Titel"), ("{nr} - {titel} - {artist}", "Nr - Titel - Artist"), ("{nr} - {titel}", "Nr - Titel"), ("{artist} - {titel}", "Artist - Titel"), ("{titel} - {artist}", "Titel - Artist"), ("{titel}", "Nur Titel"), ] FILENAME_LABELS = [label for _, label in FILENAME_FORMATS] # ═══════════════════════════════════════════════════════════════ # Platform definitions # ═══════════════════════════════════════════════════════════════ PLATFORMS = { "Spotify": { "process": "spotify.exe", "sep": " - ", "pause": {"spotify", "spotify premium", "spotify free"}, "launch_dirs": [ ("{APPDATA}", "Spotify", "Spotify.exe"), ("{LOCALAPPDATA}", "Microsoft", "WindowsApps", "Spotify.exe"), ], "cmd": "spotify", }, "Apple Music": { "process": "AppleMusic.exe", "sep": " \u2014 ", "pause": {"apple music", "itunes", "apple music preview"}, "launch_dirs": [ ("{LOCALAPPDATA}", "Microsoft", "WindowsApps", "AppleMusic.exe"), ], "cmd": None, }, "Amazon Music": { "process": "Amazon Music.exe", "sep": " - ", "pause": {"amazon music"}, "launch_dirs": [ ("{LOCALAPPDATA}", "Amazon Music", "Amazon Music.exe"), ], "cmd": None, }, "Tidal": { "process": "TIDAL.exe", "sep": " - ", "pause": {"tidal"}, "launch_dirs": [ ("{LOCALAPPDATA}", "TIDAL", "TIDAL.exe"), ], "cmd": None, }, "Deezer": { "process": "Deezer.exe", "sep": " - ", "pause": {"deezer"}, "launch_dirs": [ ("{LOCALAPPDATA}", "Deezer", "Deezer.exe"), ], "cmd": None, }, "YouTube Music": { "process": None, "sep": None, "pause": set(), "launch_dirs": [], "cmd": None, "url": "https://music.youtube.com", }, "TuneIn / Radio": { "process": None, "sep": None, "pause": set(), "launch_dirs": [], "cmd": None, "url": "https://tunein.com", }, "SoundCloud": { "process": None, "sep": None, "pause": set(), "launch_dirs": [], "cmd": None, "url": "https://soundcloud.com", }, "System Audio": { "process": None, "sep": None, "pause": set(), "launch_dirs": [], "cmd": None, }, } PLATFORM_NAMES = list(PLATFORMS.keys()) # ═══════════════════════════════════════════════════════════════ # Window helper functions (platform-generic) # ═══════════════════════════════════════════════════════════════ def get_app_pids(process_name): pids = set() if not process_name: return pids target = process_name.lower() for proc in psutil.process_iter(["pid", "name"]): try: if (proc.info["name"] or "").lower() == target: pids.add(proc.info["pid"]) except (psutil.NoSuchProcess, psutil.AccessDenied): pass return pids def find_app_window(process_name): pids = get_app_pids(process_name) if not pids: return None best = [None] best_area = [0] @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) def cb(hwnd, _lp): if user32.IsWindowVisible(hwnd): pid = wintypes.DWORD() user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) if pid.value in pids: rect = wintypes.RECT() user32.GetWindowRect(hwnd, ctypes.byref(rect)) w = rect.right - rect.left h = rect.bottom - rect.top area = w * h if area > best_area[0] and w > 300 and h > 200: best_area[0] = area best[0] = hwnd return True user32.EnumWindows(cb, 0) return best[0] def get_window_title(hwnd): length = user32.GetWindowTextLengthW(hwnd) if length > 0: buf = ctypes.create_unicode_buffer(length + 1) user32.GetWindowTextW(hwnd, buf, length + 1) return buf.value return "" def _ps_startupinfo(): """STARTUPINFO that hides the PowerShell console window.""" si = subprocess.STARTUPINFO() si.dwFlags |= subprocess.STARTF_USESHOWWINDOW si.wShowWindow = 0 # SW_HIDE return si def run_powershell(command, timeout=15): """Run a PowerShell command hidden, return (stdout, stderr).""" try: r = subprocess.run( ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], capture_output=True, text=True, timeout=timeout, startupinfo=_ps_startupinfo(), ) return r.stdout or "", r.stderr or "" except subprocess.TimeoutExpired: return "", "TIMEOUT" except Exception: return "", traceback.format_exc() def launch_app(platform_cfg): env_map = { "{APPDATA}": os.getenv("APPDATA", ""), "{LOCALAPPDATA}": os.getenv("LOCALAPPDATA", ""), "{PROGRAMFILES}": os.getenv("PROGRAMFILES", ""), } for parts in platform_cfg.get("launch_dirs", []): resolved = [] for p in parts: resolved.append(env_map.get(p, p)) path = os.path.join(*resolved) if os.path.exists(path): subprocess.Popen([path]) return True cmd = platform_cfg.get("cmd") if cmd: try: subprocess.Popen([cmd]) return True except FileNotFoundError: pass return False # ═══════════════════════════════════════════════════════════════ # Main application # ═══════════════════════════════════════════════════════════════ class SpotifyRecorderApp: def __init__(self): try: ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( "surovy.music.recorder") except Exception: pass try: ctypes.windll.shcore.SetProcessDpiAwareness(1) except Exception: pass ctk.set_appearance_mode("dark") ctk.set_default_color_theme("green") self.root = ctk.CTk() self.root.title("Surovy's Music Recorder") self.root.configure(fg_color=BG) self._ico_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), "app.ico") self._hicon_small = None self._hicon_big = None if os.path.exists(self._ico_path): self.root.iconbitmap(self._ico_path) self._load_hicons() self.root.after(200, self._apply_initial_icon) self.root.protocol("WM_DELETE_WINDOW", self._on_close) self._tick_icon() self._app_initialized = False self._show_selector() # ── platform selector ───────────────────────────────────── _PLATFORM_ICONS = { "Spotify": "\u266a", "Apple Music": "\u266a", "Amazon Music": "\u266a", "Tidal": "\u266a", "Deezer": "\u266a", "YouTube Music": "\u25b6", "TuneIn / Radio": "\U0001f4fb", "SoundCloud": "\u2601", "System Audio": "\U0001f50a", } def _show_selector(self): self.root.geometry("680x530") self.root.minsize(680, 530) self.root.resizable(False, False) self._sel = ctk.CTkFrame(self.root, fg_color=BG) self._sel.pack(fill="both", expand=True) hdr = ctk.CTkFrame(self._sel, fg_color="transparent") hdr.pack(pady=(38, 0)) logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logo.png") if os.path.exists(logo_path): sel_logo = ctk.CTkImage( light_image=Image.open(logo_path), dark_image=Image.open(logo_path), size=(64, 64)) ctk.CTkLabel(hdr, image=sel_logo, text="").pack() else: icon_box = ctk.CTkFrame(hdr, width=54, height=54, fg_color=GREEN, corner_radius=15) icon_box.pack() icon_box.pack_propagate(False) ctk.CTkLabel(icon_box, text="\u266a", font=ctk.CTkFont(size=24, weight="bold"), text_color="white").pack(expand=True) ctk.CTkLabel(hdr, text="Surovy's Music Recorder", font=ctk.CTkFont(size=22, weight="bold"), text_color=TXT).pack(pady=(14, 4)) ctk.CTkLabel(hdr, text="Dienst auswaehlen", font=ctk.CTkFont(size=13), text_color=TXT2).pack() grid = ctk.CTkFrame(self._sel, fg_color="transparent") grid.pack(pady=(28, 0)) for i, name in enumerate(PLATFORM_NAMES): row, col = divmod(i, 3) ic = self._PLATFORM_ICONS.get(name, "\u266a") btn = ctk.CTkButton( grid, text=f"{ic} {name}", width=195, height=64, corner_radius=14, font=ctk.CTkFont(size=13, weight="bold"), fg_color=CARD, hover_color="#252525", border_width=1, border_color=BORDER, text_color=TXT, command=lambda n=name: self._on_platform_selected(n), ) btn.grid(row=row, column=col, padx=8, pady=8) ctk.CTkLabel(self._sel, text="Du kannst den Dienst spaeter jederzeit wechseln.", font=ctk.CTkFont(size=10), text_color=DIM ).pack(pady=(22, 0)) def _on_platform_selected(self, name): cfg = PLATFORMS[name] proc = cfg.get("process") if proc and not get_app_pids(proc): launch_app(cfg) self._sel.destroy() self.root.resizable(True, True) self.root.geometry("1400x880") self.root.minsize(1000, 620) self.platform_name = name self.platform_cfg = cfg self.recorder = AudioRecorder() self.spotify_hwnd = None self.original_style = None self.original_parent = None self.active = False self.is_playing = False self.playlist_name = "" self.session_dir = "" self.track_number = 0 self.current_track = None self.songs = [] self.recorded_songs = set() self.rec_start = None self.last_title = "" self.output_base = os.path.join( os.path.expanduser("~"), "Music", "SpotifyRecordings" ) os.makedirs(self.output_base, exist_ok=True) self.bitrate = 320 self.auto_stop_secs = 60 self.pause_since = None self._pulse_on = True self._alive_fails = 0 self.ad_playing = False self._auto_record_ready = False self.playlist_total = 0 self._user_stopped = False self._restart_after_id = None self._restart_fails = 0 self._lock = threading.Lock() self._silence_start = None self._silence_threshold = 0.012 self._min_silence_secs = 2.0 self._folder_mode = "auto" self._custom_folder = "" self._detecting = False self._last_suggestion = "" self._fn_format = FILENAME_FORMATS[0][0] self._web_process = None self._skip_count = 0 self._max_skips = 80 self._empty_title_count = 0 self._cache = {"all_songs": [], "playlist_name": "", "total_tracks": 0} self._cached_song_keys = set() self._dialog_paused = False self._dialog_paused_title = "" self._app_initialized = True self._build_controls() self._build_embed_area() threading.Thread(target=self._attach_app, daemon=True).start() self._tick_monitor() self._tick_timer() self._tick_level() self._tick_pulse() self._tick_alive() self._tick_suggest() # ── build left panel ─────────────────────────────────────── def _build_controls(self): panel = ctk.CTkFrame(self.root, width=PANEL_W, fg_color=CARD, corner_radius=0, border_width=0) panel.pack(side="left", fill="y") panel.pack_propagate(False) pad = {"padx": 20} # header hdr = ctk.CTkFrame(panel, fg_color="transparent") hdr.pack(fill="x", **pad, pady=(22, 0)) logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logo.png") if os.path.exists(logo_path): logo_img = ctk.CTkImage( light_image=Image.open(logo_path), dark_image=Image.open(logo_path), size=(42, 42)) ctk.CTkLabel(hdr, image=logo_img, text="").pack(side="left") else: icon = ctk.CTkFrame(hdr, width=42, height=42, fg_color=GREEN, corner_radius=12) icon.pack(side="left") icon.pack_propagate(False) ctk.CTkLabel(icon, text="\u266a", font=ctk.CTkFont(size=20, weight="bold"), text_color="white").pack(expand=True) hcol = ctk.CTkFrame(hdr, fg_color="transparent") hcol.pack(side="left", padx=10) ctk.CTkLabel(hcol, text="Surovy's", font=ctk.CTkFont(size=10), text_color=TXT).pack(anchor="w") ctk.CTkLabel(hcol, text="Music Recorder", font=ctk.CTkFont(size=16, weight="bold"), text_color=TXT).pack(anchor="w") # platform selector src_row = ctk.CTkFrame(panel, fg_color="transparent") src_row.pack(fill="x", **pad, pady=(14, 0)) ctk.CTkLabel(src_row, text="QUELLE", font=ctk.CTkFont(size=9, weight="bold"), text_color=DIM).pack(side="left") self.platform_var = ctk.StringVar(value=self.platform_name) ctk.CTkOptionMenu( src_row, values=PLATFORM_NAMES, variable=self.platform_var, width=170, height=28, font=ctk.CTkFont(size=11), fg_color="#2a2a2a", button_color="#333", button_hover_color="#444", command=self._change_platform ).pack(side="right") # connection status self.sp_label = ctk.CTkLabel( panel, text="\u25cf Spotify wird gesucht\u2026", font=ctk.CTkFont(size=11), text_color=DIM) self.sp_label.pack(**pad, pady=(8, 0), anchor="w") # separator ctk.CTkFrame(panel, height=1, fg_color=BORDER).pack(fill="x", **pad, pady=10) # folder settings (directly below connection status) fm_row = ctk.CTkFrame(panel, fg_color="transparent") fm_row.pack(fill="x", **pad, pady=(0, 0)) ctk.CTkLabel(fm_row, text="ZIELORDNER", font=ctk.CTkFont(size=9, weight="bold"), text_color=DIM).pack(side="left") self.fm_var = ctk.StringVar(value="Automatisch") ctk.CTkOptionMenu( fm_row, values=["Automatisch", "Eigener Ordner"], variable=self.fm_var, width=140, height=26, font=ctk.CTkFont(size=11), fg_color="#2a2a2a", button_color="#333", button_hover_color="#444", command=self._change_folder_mode ).pack(side="right") fr = ctk.CTkFrame(panel, fg_color="transparent") fr.pack(fill="x", **pad, pady=(6, 0)) ctk.CTkButton(fr, text="Aendern", width=65, height=26, corner_radius=8, font=ctk.CTkFont(size=11), fg_color="#2a2a2a", hover_color="#383838", command=self._choose_folder).pack(side="right") ctk.CTkButton(fr, text="\U0001f4c2 Oeffnen", width=80, height=26, corner_radius=8, font=ctk.CTkFont(size=11), fg_color="#2a2a2a", hover_color="#383838", command=self._open_folder).pack(side="right", padx=(0, 6)) self.check_btn = ctk.CTkButton( fr, text="\U0001f50d Pruefen", width=75, height=26, corner_radius=8, font=ctk.CTkFont(size=11), fg_color="#2a2a2a", hover_color="#383838", command=self._check_playlist) self.check_btn.pack(side="right", padx=(0, 6)) self.folder_lbl = ctk.CTkLabel( panel, text=self.output_base, font=ctk.CTkFont(size=12), text_color=GREEN, wraplength=PANEL_W - 50, anchor="w") self.folder_lbl.pack(**pad, anchor="w", pady=(4, 0)) self.folder_hint_lbl = ctk.CTkLabel( panel, text="Erstellt Unterordner pro Playlist", font=ctk.CTkFont(size=9), text_color="#444") self.folder_hint_lbl.pack(**pad, anchor="w", pady=(0, 0)) # separator ctk.CTkFrame(panel, height=1, fg_color=BORDER).pack(fill="x", **pad, pady=8) # playlist input ctk.CTkLabel(panel, text="ORDNER / PLAYLIST", font=ctk.CTkFont(size=9, weight="bold"), text_color=DIM).pack(**pad, anchor="w") self.pl_entry = ctk.CTkEntry( panel, placeholder_text="Optional - wird auto-generiert", font=ctk.CTkFont(size=13), height=38, corner_radius=10, fg_color="#1a1a1a", border_color=BORDER) self.pl_entry.pack(fill="x", **pad, pady=(6, 0)) self.pl_hint_lbl = ctk.CTkLabel( panel, text="Leer lassen = automatisch (Datum/Uhrzeit)", font=ctk.CTkFont(size=9), text_color="#444") self.pl_hint_lbl.pack(**pad, anchor="w", pady=(2, 0)) ar_chk_row = ctk.CTkFrame(panel, fg_color="transparent") ar_chk_row.pack(fill="x", **pad, pady=(8, 0)) self.auto_rec_var = ctk.BooleanVar(value=True) self.auto_rec_chk = ctk.CTkCheckBox( ar_chk_row, text="Auto-Aufnahme bei Play", variable=self.auto_rec_var, font=ctk.CTkFont(size=12), text_color=TXT2, fg_color="#333", hover_color="#444", checkmark_color="white", border_color=BORDER, corner_radius=4) self.auto_rec_chk.pack(side="left") self.top_folder_lbl = None # track card tcard = ctk.CTkFrame(panel, fg_color="#111111", corner_radius=14, border_width=1, border_color=BORDER) tcard.pack(fill="x", **pad, pady=(14, 0)) srow = ctk.CTkFrame(tcard, fg_color="transparent") srow.pack(fill="x", padx=14, pady=(12, 0)) self.dot_lbl = ctk.CTkLabel(srow, text="\u25cf", width=14, font=ctk.CTkFont(size=10), text_color=DIM) self.dot_lbl.pack(side="left") self.stat_lbl = ctk.CTkLabel(srow, text="BEREIT", font=ctk.CTkFont(size=10, weight="bold"), text_color=DIM) self.stat_lbl.pack(side="left", padx=(5, 0)) self.time_lbl = ctk.CTkLabel(srow, text="", font=ctk.CTkFont(size=12, weight="bold"), text_color=DIM) self.time_lbl.pack(side="right") ctk.CTkFrame(tcard, height=1, fg_color=BORDER).pack(fill="x", padx=14, pady=8) self.tnum_lbl = ctk.CTkLabel(tcard, text="", font=ctk.CTkFont(size=10, weight="bold"), text_color=GREEN) self.tnum_lbl.pack(padx=14, anchor="w") self.artist_lbl = ctk.CTkLabel(tcard, text="\u2014", font=ctk.CTkFont(size=15, weight="bold"), text_color=TXT) self.artist_lbl.pack(padx=14, anchor="w") self.title_lbl = ctk.CTkLabel(tcard, text="", font=ctk.CTkFont(size=12), text_color=TXT2) self.title_lbl.pack(padx=14, anchor="w", pady=(1, 0)) lvl_row = ctk.CTkFrame(tcard, fg_color="transparent") lvl_row.pack(fill="x", padx=14, pady=(12, 14)) ctk.CTkLabel(lvl_row, text="PEGEL", font=ctk.CTkFont(size=8, weight="bold"), text_color="#444").pack(side="left", padx=(0, 8)) self.level_bar = ctk.CTkProgressBar( lvl_row, height=5, corner_radius=3, fg_color="#222", progress_color=GREEN) self.level_bar.pack(side="left", fill="x", expand=True) self.level_bar.set(0) # record button self.rec_btn = ctk.CTkButton( panel, text="\u23fa AUFNAHME STARTEN", font=ctk.CTkFont(size=14, weight="bold"), height=48, corner_radius=24, fg_color=GREEN, hover_color=GREEN_H, command=self._toggle) self.rec_btn.pack(fill="x", **pad, pady=(14, 0)) # song list ctk.CTkFrame(panel, height=1, fg_color=BORDER).pack(fill="x", **pad, pady=12) sl_hdr = ctk.CTkFrame(panel, fg_color="transparent") sl_hdr.pack(fill="x", **pad) ctk.CTkLabel(sl_hdr, text="AUFGENOMMEN", font=ctk.CTkFont(size=9, weight="bold"), text_color=DIM).pack(side="left") self.cnt_lbl = ctk.CTkLabel(sl_hdr, text="0 Songs", font=ctk.CTkFont(size=9, weight="bold"), text_color=DIM) self.cnt_lbl.pack(side="right") self.song_box = ctk.CTkTextbox( panel, font=ctk.CTkFont(size=11), fg_color=CARD, text_color=TXT2, border_width=0, corner_radius=0, state="disabled", height=120, activate_scrollbars=True) self.song_box.pack(fill="both", expand=True, **pad, pady=(6, 0)) # settings gear button + total stats ctk.CTkFrame(panel, height=1, fg_color=BORDER).pack(fill="x", **pad, pady=(10, 0)) bottom_row = ctk.CTkFrame(panel, fg_color="transparent") bottom_row.pack(fill="x", **pad, pady=(6, 0)) self.total_lbl = ctk.CTkLabel( bottom_row, text="", font=ctk.CTkFont(size=10), text_color=DIM) self.total_lbl.pack(side="left") ctk.CTkButton( bottom_row, text="\u2699 Einstellungen", width=130, height=30, corner_radius=10, font=ctk.CTkFont(size=12, weight="bold"), fg_color="#2a2a2a", hover_color="#383838", text_color=TXT2, command=self._open_settings ).pack(side="right") self.br_var = ctk.StringVar(value="320 kbps") self.as_var = ctk.StringVar(value="60 Sek.") self._fn_label_var = ctk.StringVar(value=FILENAME_LABELS[0]) self._refresh_total_stats() # ── build right embed area ───────────────────────────────── def _build_embed_area(self): self.embed_outer = ctk.CTkFrame(self.root, fg_color="#000000", corner_radius=0) self.embed_outer.pack(side="right", fill="both", expand=True) self.embed_frame = tk.Frame(self.embed_outer, bg="#000000") self.embed_frame.pack(fill="both", expand=True) self.embed_msg = ctk.CTkLabel( self.embed_outer, text="", font=ctk.CTkFont(size=15), text_color="#444", justify="center") self.embed_msg.place(relx=0.5, rely=0.5, anchor="center") self._update_embed_msg() self.embed_frame.bind("", self._on_embed_resize) # ── app embedding (generic) ──────────────────────────────── def _update_embed_msg(self): cfg = self.platform_cfg name = self.platform_name if cfg.get("process"): self.embed_msg.configure( text=f"\u23f3 {name} wird gesucht\u2026\n\n" f"Stelle sicher, dass die {name} Desktop-App\n" "geoeffnet ist (bereits eingeloggt).") elif cfg.get("url"): self.embed_msg.configure( text=f"\u23f3 {name} wird geladen\u2026") else: self.embed_msg.configure( text=f"\U0001f50a {name}\n\n" f"Spiele Musik in deinem Browser oder einer\n" "beliebigen App ab. Die Aufnahme erfasst\n" "den gesamten System-Audio-Ausgang.\n\n" "\u25b6 Druecke REC, wenn du bereit bist.") def _attach_app(self): cfg = self.platform_cfg name = self.platform_name proc = cfg.get("process") url = cfg.get("url") if not proc and url: self._attach_web_app(url, name) return if not proc: self.root.after(0, lambda: self._update_sp_status(False, system_audio=True)) return for _ in range(30): hwnd = find_app_window(proc) if hwnd: self.root.after(100, lambda h=hwnd: self._embed(h)) return time.sleep(1) if not get_app_pids(proc): self.root.after(0, lambda: self.embed_msg.configure( text=f"\u26a0 {name} nicht gefunden.\n\n" f"Starte die {name} Desktop-App\n" "und klicke dann hier.", )) launched = launch_app(cfg) if launched: for _ in range(20): hwnd = find_app_window(proc) if hwnd: self.root.after(100, lambda h=hwnd: self._embed(h)) return time.sleep(1) self.root.after(0, lambda: self._update_sp_status(False)) # ── web app embedding (Edge --app) ────────────────────────── @staticmethod def _find_edge_exe(): candidates = [] for env in ("PROGRAMFILES(X86)", "PROGRAMFILES", "LOCALAPPDATA"): base = os.getenv(env, "") if base: candidates.append(os.path.join( base, "Microsoft", "Edge", "Application", "msedge.exe")) for p in candidates: if os.path.exists(p): return p return None def _attach_web_app(self, url, name): edge_exe = self._find_edge_exe() if not edge_exe: self.root.after(0, lambda: self.embed_msg.configure( text=f"\u26a0 Microsoft Edge nicht gefunden.\n\n" f"Oeffne {url}\n" "manuell in einem Browser.")) self.root.after(0, lambda: self._update_sp_status(False, system_audio=True)) return self.root.after(0, lambda: self.embed_msg.configure( text=f"\u23f3 {name} wird geladen\u2026")) self._web_process = subprocess.Popen([ edge_exe, f"--app={url}", "--no-first-run", ]) time.sleep(3) self.root.after(0, lambda n=name: self.embed_msg.configure( text=f"\U0001f310 {n}\n\n" f"{n} laeuft in einem separaten Fenster.\n" "Spiele dort Musik ab und druecke hier REC.\n\n" "Die Aufnahme erfasst den System-Audio-Ausgang.")) self.root.after(0, lambda: self._update_sp_status(False, system_audio=True)) def _embed(self, hwnd): self.embed_frame.update_idletasks() container = self.embed_frame.winfo_id() self.original_style = user32.GetWindowLongW(hwnd, GWL_STYLE) self.original_parent = user32.GetParent(hwnd) self.spotify_hwnd = hwnd user32.SetParent(hwnd, container) style = self.original_style style &= ~WS_CAPTION style &= ~WS_THICKFRAME style &= ~WS_POPUP style |= WS_CHILD user32.SetWindowLongW(hwnd, GWL_STYLE, style) user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER) w = self.embed_frame.winfo_width() h = self.embed_frame.winfo_height() user32.MoveWindow(hwnd, 0, 0, max(w, 400), max(h, 300), True) self.embed_msg.place_forget() self._update_sp_status(True) def _on_embed_resize(self, event): if self.spotify_hwnd and user32.IsWindow(self.spotify_hwnd): user32.MoveWindow(self.spotify_hwnd, 0, 0, event.width, event.height, True) def _restore_spotify(self): if self.spotify_hwnd and self.original_style is not None: try: if user32.IsWindow(self.spotify_hwnd): user32.SetParent(self.spotify_hwnd, self.original_parent or 0) user32.SetWindowLongW(self.spotify_hwnd, GWL_STYLE, self.original_style) user32.SetWindowPos(self.spotify_hwnd, 0, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER) user32.ShowWindow(self.spotify_hwnd, 5) except Exception: pass self.spotify_hwnd = None self._kill_web_process() def _kill_web_process(self): if self._web_process: try: self._web_process.terminate() except Exception: pass self._web_process = None def _update_sp_status(self, connected, system_audio=False): name = self.platform_name if system_audio: self.sp_label.configure( text=f"\u25cf {name} \u2013 System Audio Modus", text_color="#888") elif connected: self.sp_label.configure( text=f"\u25cf {name} verbunden", text_color=GREEN) else: self.sp_label.configure( text=f"\u25cf {name} nicht verbunden", text_color=RED) # ── live playlist suggestion ──────────────────────────────── def _tick_suggest(self): if (not self.active and self.platform_cfg.get("process") and self.spotify_hwnd and user32.IsWindow(self.spotify_hwnd) and not self._detecting): self._detecting = True threading.Thread(target=self._suggest_async, daemon=True).start() self.root.after(4000, self._tick_suggest) def _suggest_async(self): try: name, _ = self._detect_playlist_info() if name: self.root.after(0, lambda n=name: self._show_suggestion(n)) except Exception: pass finally: self._detecting = False def _show_suggestion(self, name): if self.active: return current = self.pl_entry.get().strip() if not current or current == self._last_suggestion: self.pl_entry.delete(0, "end") self.pl_entry.insert(0, name) self._last_suggestion = name if self._folder_mode == "auto" and name: target = os.path.join(self.output_base, name) self._lbl_set(self.folder_lbl, target) def _change_platform(self, new_name): if self.active: self.platform_var.set(self.platform_name) return if new_name == self.platform_name: return self._restore_spotify() self.platform_name = new_name self.platform_cfg = PLATFORMS[new_name] self._auto_record_ready = False self.is_playing = False self.last_title = "" self._alive_fails = 0 self._update_embed_msg() if self.platform_cfg.get("process"): self.embed_msg.place(relx=0.5, rely=0.5, anchor="center") else: self.embed_msg.place(relx=0.5, rely=0.5, anchor="center") threading.Thread(target=self._attach_app, daemon=True).start() # ── folder mode ──────────────────────────────────────────── def _lbl_set(self, lbl, text): if lbl and lbl.winfo_exists(): lbl.configure(text=text) def _change_folder_mode(self, val): if val == "Eigener Ordner": if not self._custom_folder: p = filedialog.askdirectory(initialdir=self.output_base) if p: self._custom_folder = p else: self.fm_var.set("Automatisch") return self._folder_mode = "custom" self._lbl_set(self.folder_lbl, self._custom_folder) self._lbl_set(self.folder_hint_lbl, "Songs direkt in diesen Ordner (kein Unterordner)") self._lbl_set(self.pl_hint_lbl, "Optional \u2013 fuer Album-Tag in MP3") folder_name = os.path.basename(self._custom_folder) self.pl_entry.delete(0, "end") self.pl_entry.insert(0, folder_name) self._last_suggestion = folder_name else: self._folder_mode = "auto" self._lbl_set(self.folder_lbl, self.output_base) self._lbl_set(self.folder_hint_lbl, "Erstellt Unterordner pro Playlist") self._lbl_set(self.pl_hint_lbl, "Leer lassen = automatisch (Datum/Uhrzeit)") self.pl_entry.delete(0, "end") self._last_suggestion = "" self._refresh_total_stats() def _set_filename_format(self, label): for fmt, lbl in FILENAME_FORMATS: if lbl == label: self._fn_format = fmt break self._update_fn_preview() # ── settings dialog ────────────────────────────────────────── def _open_settings(self): if hasattr(self, "_settings_win") and self._settings_win and self._settings_win.winfo_exists(): self._settings_win.focus() return win = ctk.CTkToplevel(self.root) self._settings_win = win win.title("Einstellungen") win.geometry("420x400") win.resizable(False, False) win.configure(fg_color=BG) win.transient(self.root) win.grab_set() if os.path.exists(self._ico_path): win.after(200, lambda: win.iconbitmap(self._ico_path)) win.after(300, lambda: self._set_toplevel_icon(win)) pad = {"padx": 20} ctk.CTkLabel(win, text="\u2699 Einstellungen", font=ctk.CTkFont(size=18, weight="bold"), text_color=TXT).pack(**pad, pady=(20, 18), anchor="w") # -- Dateiname -- ctk.CTkLabel(win, text="DATEINAME", font=ctk.CTkFont(size=9, weight="bold"), text_color=DIM).pack(**pad, anchor="w") fn_row = ctk.CTkFrame(win, fg_color="transparent") fn_row.pack(fill="x", **pad, pady=(6, 0)) ctk.CTkLabel(fn_row, text="Format", font=ctk.CTkFont(size=12), text_color=TXT2).pack(side="left") ctk.CTkOptionMenu( fn_row, values=FILENAME_LABELS, variable=self._fn_label_var, width=190, height=28, font=ctk.CTkFont(size=11), fg_color="#2a2a2a", button_color="#333", button_hover_color="#444", command=self._set_filename_format ).pack(side="right") self._fn_preview_lbl = ctk.CTkLabel( win, text="", font=ctk.CTkFont(size=10), text_color="#666") self._fn_preview_lbl.pack(**pad, anchor="w", pady=(4, 0)) self._update_fn_preview() ctk.CTkFrame(win, height=1, fg_color=BORDER).pack(fill="x", **pad, pady=(12, 12)) # -- Qualitaet -- ctk.CTkLabel(win, text="AUFNAHME", font=ctk.CTkFont(size=9, weight="bold"), text_color=DIM).pack(**pad, anchor="w") br_row = ctk.CTkFrame(win, fg_color="transparent") br_row.pack(fill="x", **pad, pady=(6, 0)) ctk.CTkLabel(br_row, text="Qualitaet", font=ctk.CTkFont(size=12), text_color=TXT2).pack(side="left") ctk.CTkOptionMenu( br_row, values=["128 kbps", "192 kbps", "256 kbps", "320 kbps"], variable=self.br_var, width=130, height=28, font=ctk.CTkFont(size=11), fg_color="#2a2a2a", button_color="#333", button_hover_color="#444", command=self._set_bitrate ).pack(side="right") as_row = ctk.CTkFrame(win, fg_color="transparent") as_row.pack(fill="x", **pad, pady=(10, 0)) ctk.CTkLabel(as_row, text="Auto-Stop", font=ctk.CTkFont(size=12), text_color=TXT2).pack(side="left") ctk.CTkOptionMenu( as_row, values=["15 Sek.", "30 Sek.", "60 Sek.", "120 Sek.", "Aus"], variable=self.as_var, width=130, height=28, font=ctk.CTkFont(size=11), fg_color="#2a2a2a", button_color="#333", button_hover_color="#444", command=self._set_auto_stop ).pack(side="right") ctk.CTkLabel(win, text="Stoppt Aufnahme wenn Playlist endet", font=ctk.CTkFont(size=9), text_color="#444" ).pack(**pad, anchor="w", pady=(2, 0)) ctk.CTkButton( win, text="Schliessen", width=120, height=34, corner_radius=12, font=ctk.CTkFont(size=13, weight="bold"), fg_color=GREEN, hover_color=GREEN_H, command=win.destroy ).pack(pady=(20, 16)) def _update_fn_preview(self): preview = self._fn_format.format( nr="01", artist="Kuenstler", titel="Songname") if hasattr(self, "_fn_preview_lbl") and self._fn_preview_lbl.winfo_exists(): self._fn_preview_lbl.configure(text=f"Vorschau: {preview}.mp3") # ── recording control ────────────────────────────────────── def _toggle(self): if self.active: self._user_stopped = True self._stop() else: self._start() def _start(self): if self._restart_after_id: self.root.after_cancel(self._restart_after_id) self._restart_after_id = None if self._folder_mode == "custom" and not self._custom_folder: self._set_status("ORDNER WAEHLEN!", RED) return has_app = self.platform_cfg.get("process") if has_app and (not self.spotify_hwnd or not user32.IsWindow(self.spotify_hwnd)): self._set_status(f"{self.platform_name} FEHLT", RED) return self._user_stopped = False self._skip_count = 0 user_name = self.pl_entry.get().strip() self.rec_btn.configure(state="disabled") self._set_status("PLAYLIST WIRD ERKANNT\u2026", GREEN) threading.Thread( target=self._start_async, args=(user_name,), daemon=True ).start() def _start_async(self, user_name): has_app = self.platform_cfg.get("process") sep = self.platform_cfg.get("sep") detected_name, total = None, 0 if has_app and self.spotify_hwnd: detected_name, total = self._detect_playlist_info() if self._folder_mode == "custom": name = (user_name or detected_name or os.path.basename(self._custom_folder)) if has_app and self.spotify_hwnd: title = get_window_title(self.spotify_hwnd) if not title or (sep and sep not in title): self._send_play() time.sleep(2) self.root.after( 0, lambda n=name, t=total: self._start_finish(n, t)) return if not user_name and not detected_name: if has_app and self.spotify_hwnd: cur_title = get_window_title(self.spotify_hwnd) self._dialog_paused_title = cur_title self._dialog_paused = True self._send_pause() prefix = self.platform_name.replace(" ", "") suggestion = datetime.datetime.now().strftime( f"{prefix}_%Y-%m-%d_%H-%M") self.root.after( 0, lambda s=suggestion, t=total: self._ask_folder_name(s, t)) return name = user_name or detected_name if has_app and self.spotify_hwnd: title = get_window_title(self.spotify_hwnd) if not title or (sep and sep not in title): self._send_play() time.sleep(2) self.root.after(0, lambda n=name, t=total: self._start_finish(n, t)) def _ask_folder_name(self, suggestion, total): """Show dialog to ask user for a folder name. Spotify is paused while this dialog is open. """ result = {"value": None} win = ctk.CTkToplevel(self.root) win.title("Surovy's Music Recorder \u2013 Ordnername") win.geometry("480x260") win.resizable(False, False) win.configure(fg_color=BG) win.transient(self.root) win.grab_set() if os.path.exists(self._ico_path): win.after(200, lambda: win.iconbitmap(self._ico_path)) win.after(300, lambda: self._set_toplevel_icon(win)) if self._dialog_paused: ctk.CTkLabel( win, text="\u23f8 Spotify pausiert", font=ctk.CTkFont(size=15, weight="bold"), text_color=ORANGE).pack(pady=(18, 2)) ctk.CTkLabel( win, text="Kein Playlist-Name erkannt.\n" "Ordnername eingeben:", font=ctk.CTkFont(size=12), text_color=TXT2, justify="center").pack(pady=(6, 8)) entry = ctk.CTkEntry( win, width=380, height=38, font=ctk.CTkFont(size=14), placeholder_text="Ordnername") entry.pack(pady=(0, 14)) entry.insert(0, suggestion) entry.select_range(0, "end") entry.focus() btn_row = ctk.CTkFrame(win, fg_color="transparent") btn_row.pack(pady=(4, 18)) def on_ok(): result["value"] = entry.get().strip() or suggestion win.destroy() def on_cancel(): result["value"] = None win.destroy() ctk.CTkButton( btn_row, text="\u25b6 Aufnahme starten", width=180, height=38, corner_radius=12, font=ctk.CTkFont(size=13, weight="bold"), fg_color=GREEN, hover_color=GREEN_H, command=on_ok).pack(side="left", padx=(0, 10)) ctk.CTkButton( btn_row, text="Abbrechen", width=130, height=38, corner_radius=12, font=ctk.CTkFont(size=13), fg_color="#333333", hover_color="#444444", command=on_cancel).pack(side="left") entry.bind("", lambda e: on_ok()) entry.bind("", lambda e: on_cancel()) win.protocol("WM_DELETE_WINDOW", on_cancel) win.wait_window() if result["value"] is None: if self._dialog_paused: self._dialog_paused = False self._dialog_paused_title = "" self._send_play() self.rec_btn.configure(state="normal") self._set_status("BEREIT", DIM) return self._start_finish(result["value"], total) def _start_finish(self, name, total=0): has_app = self.platform_cfg.get("process") if has_app and (not self.spotify_hwnd or not user32.IsWindow(self.spotify_hwnd)): self._set_status(f"{self.platform_name} FEHLT", RED) self.rec_btn.configure(state="normal") return self.pl_entry.configure(state="normal") self.pl_entry.delete(0, "end") self.pl_entry.insert(0, name) if self._folder_mode == "custom" and self._custom_folder: self.playlist_name = name self.session_dir = self._custom_folder else: safe = name for ch in '<>:"/\\|?*': safe = safe.replace(ch, "_") self.playlist_name = name self.session_dir = os.path.join(self.output_base, safe) os.makedirs(self.session_dir, exist_ok=True) self._lbl_set(self.folder_lbl, self.session_dir) try: self.recorder.start() except RuntimeError as e: self._set_status(str(e)[:40], RED) self.rec_btn.configure(state="normal") return self._prevent_sleep() self.recorded_songs = self._scan_existing_songs() existing = len(self.recorded_songs) max_num = self._get_max_track_num() self._cache = self._load_cache() self._cached_song_keys = set() for s in self._cache.get("all_songs", []): self._cached_song_keys.add( self._song_key(s["artist"], s["title"])) self._cache["playlist_name"] = name if total > 0: self._cache["total_tracks"] = total cached_total = self._cache.get("total_tracks", 0) if cached_total > 0 and total == 0: total = cached_total self._save_cache() if total > 0 and existing >= total: self.recorder.stop() self._allow_sleep() self.rec_btn.configure( text="\u23fa AUFNAHME STARTEN", fg_color=GREEN, hover_color=GREEN_H, state="normal") self.pl_entry.configure(state="normal") self._set_status( f"ALLE {total} SONGS VORHANDEN", GREEN) self.cnt_lbl.configure( text=f"{existing} / {total} Songs") self.tnum_lbl.configure(text=f"\u2714 {total} SONGS") self.artist_lbl.configure(text="Playlist vollstaendig") self.title_lbl.configure( text="Alle Songs bereits aufgenommen") return self.active = True if total > 0: self.playlist_total = total self.track_number = max(existing, max_num) self.current_track = None self.songs = [] self.last_title = "" self.pause_since = None was_paused = self._dialog_paused paused_title = self._dialog_paused_title self._dialog_paused = False self._dialog_paused_title = "" skipping = False sep = self.platform_cfg.get("sep") has_app = self.platform_cfg.get("process") if was_paused and paused_title and sep and sep in paused_title: title = paused_title else: title = (get_window_title(self.spotify_hwnd) if self.spotify_hwnd else "") if title and sep and sep in title: parts = title.split(sep, 1) artist, song = parts[0].strip(), parts[1].strip() self.last_title = title self.is_playing = not was_paused self._cache_song(artist, song) if self._song_key(artist, song) in self.recorded_songs: skipping = True self.artist_lbl.configure(text=artist) self.title_lbl.configure( text=f"\u2714 {song}") self.tnum_lbl.configure(text="BEREITS AUFGENOMMEN") self._skip_count = 0 if not was_paused: self._skip_track() else: self.track_number += 1 self.current_track = {"artist": artist, "title": song} self._update_track_ui() elif not has_app: self.track_number = 1 ts = datetime.datetime.now().strftime("%H-%M-%S") self.current_track = { "artist": self.platform_name, "title": f"Recording {ts}", } self.is_playing = True self.artist_lbl.configure(text=self.platform_name) self.title_lbl.configure(text="System Audio Aufnahme") self.tnum_lbl.configure(text="AUFNAHME LAEUFT") self.rec_start = time.time() if self.playlist_total > 0: self.cnt_lbl.configure( text=f"{existing} / {self.playlist_total} Songs") elif existing > 0: self.cnt_lbl.configure(text=f"{existing} bereits vorhanden") self.rec_btn.configure( text="\u23f9 AUFNAHME STOPPEN", fg_color=RED, hover_color=RED_H, state="normal") self.pl_entry.configure(state="disabled") if was_paused: self._set_status("\u23f5 SONG WIRD NEUGESTARTET\u2026", GREEN) def _do_resume(): self._restart_and_play() threading.Thread(target=_do_resume, daemon=True).start() elif skipping: self._set_status("\u23ed WARTE AUF NEUEN SONG", ORANGE) else: self._set_status("AUFNAHME", GREEN) def _send_play(self): """Send media play/pause key to start Spotify playback.""" user32.keybd_event(0xB3, 0, 0, 0) time.sleep(0.05) user32.keybd_event(0xB3, 0, 2, 0) def _send_pause(self): """Pause Spotify via media play/pause key (same key toggles).""" self._send_play() def _restart_and_play(self): """Send prev-track to restart current song, then play.""" user32.keybd_event(VK_MEDIA_PREV_TRACK, 0, 0, 0) time.sleep(0.05) user32.keybd_event(VK_MEDIA_PREV_TRACK, 0, KEYEVENTF_KEYUP, 0) time.sleep(0.6) self._send_play() def _skip_track(self): """Send media next-track key to skip to the next song in Spotify.""" if not self.platform_cfg.get("process"): return if self._skip_count >= self._max_skips: return self._skip_count += 1 user32.keybd_event(VK_MEDIA_NEXT_TRACK, 0, 0, 0) time.sleep(0.05) user32.keybd_event(VK_MEDIA_NEXT_TRACK, 0, KEYEVENTF_KEYUP, 0) def _get_spotify_pid(self): """Get the PID of the embedded Spotify window.""" if not self.spotify_hwnd: return 0 pid = wintypes.DWORD() user32.GetWindowThreadProcessId(self.spotify_hwnd, ctypes.byref(pid)) return pid.value def _detect_playlist_info(self): """Detect playlist name AND track count via UI Automation + cache.""" cached_name = self._cache.get("playlist_name", "") cached_total = self._cache.get("total_tracks", 0) if not self.spotify_hwnd: return cached_name or None, cached_total pid = self._get_spotify_pid() if not pid: return cached_name or None, cached_total ps = ( "$ErrorActionPreference='SilentlyContinue';" "Add-Type -AssemblyName UIAutomationClient;" "Add-Type -AssemblyName UIAutomationTypes;" "$root=[System.Windows.Automation.AutomationElement]" "::RootElement;" "$pc=[System.Windows.Automation.PropertyCondition]::new(" "[System.Windows.Automation.AutomationElement]" "::ProcessIdProperty," f"{pid});" "$els=$root.FindAll(" "[System.Windows.Automation.TreeScope]::Descendants,$pc);" "$cnt=0;" "foreach($l in $els){" "if($cnt -ge 400){break};" "$n=$l.Current.Name;" "$b=$l.Current.BoundingRectangle;" "if($n -and $n.Length -ge 2){" "if($b.Width -gt 0){" "'{0}|{1}|{2}' -f [int]$b.Height,[int]$b.Bottom,$n" "}else{" "'0|0|'+$n" "};" "$cnt++}}" ) stdout1, stderr1 = run_powershell(ps, timeout=8) raw = [ln.strip() for ln in stdout1.split("\n") if ln.strip() and "|" in ln] try: log = os.path.join( os.path.dirname(os.path.abspath(__file__)), "ui_automation_debug.log") with open(log, "w", encoding="utf-8") as f: f.write(f"[{datetime.datetime.now()}] " f"pid={pid} elements={len(raw)}\n") if stderr1: f.write(f"stderr: {stderr1}\n") for line in raw[:200]: f.write(f" {line}\n") except Exception: pass if not raw: return cached_name or None, cached_total entries = [] track_count = 0 count_re = re.compile( r'(\d+)\s*(songs?|titel|tracks?|titres?|morceaux' r'|canciones|brani|lied)', re.I) time_re = re.compile(r'^\d{1,2}:\d{2}$') for line in raw: parts_raw = line.split("|", 2) if len(parts_raw) == 3: try: height = int(parts_raw[0]) pos_int = int(parts_raw[1]) except ValueError: continue nm = parts_raw[2] elif len(parts_raw) == 2: try: pos_int = int(parts_raw[0]) except ValueError: continue nm = parts_raw[1] height = 0 else: continue m = count_re.search(nm) if m: track_count = int(m.group(1)) continue entries.append((height, pos_int, nm)) title = get_window_title(self.spotify_hwnd) sep = self.platform_cfg.get("sep", " - ") skip = { "spotify", "home", "search", "your library", "library", "play", "pause", "next", "previous", "shuffle", "repeat", "queue", "volume", "connect to a device", "lyrics", "settings", "profile", "close", "minimize", "maximize", "now playing view", "premium", "free", "install app", "create playlist", "liked songs", "your episodes", "what's new", "open in desktop app", "download", "go back", "go forward", "resize", "explicit", "now playing", "connect", "what\u2019s new", "tidal", "apple music", "amazon music", "deezer", "mehr anzeigen", "weniger anzeigen", "filter", "sortieren", "sort", "mehr", "more", } skip.update(self.platform_cfg.get("pause", set())) if title and sep and sep in title: parts = title.split(sep, 1) skip.add(parts[0].strip().lower()) skip.add(parts[1].strip().lower()) entries.sort(key=lambda x: x[0], reverse=True) playlist_name = None for _h, _p, nm in entries: lower = nm.lower().strip() if lower in skip: continue if len(nm) < 2: continue if time_re.match(nm): continue if nm.isdigit(): continue if len(nm) > 120: continue playlist_name = nm break if not playlist_name and cached_name: playlist_name = cached_name if track_count == 0 and cached_total > 0: track_count = cached_total return playlist_name, track_count def _read_visible_songs(self): """Read currently visible ListItem song entries via UI Automation.""" if not self.spotify_hwnd: return [] try: hwnd_val = int(self.spotify_hwnd) except (TypeError, ValueError): return [] ps = ( "$ErrorActionPreference='SilentlyContinue';" "Add-Type -AssemblyName UIAutomationClient;" "Add-Type -AssemblyName UIAutomationTypes;" "$r=[System.Windows.Automation.AutomationElement]" f"::FromHandle([IntPtr]::new({hwnd_val}));" "if($r){" "$li=[System.Windows.Automation.PropertyCondition]::new(" "[System.Windows.Automation.AutomationElement]" "::ControlTypeProperty," "[System.Windows.Automation.ControlType]::ListItem);" "$items=$r.FindAll(" "[System.Windows.Automation.TreeScope]::Descendants,$li);" "foreach($item in $items){" "$tc=[System.Windows.Automation.OrCondition]::new(" "[System.Windows.Automation.PropertyCondition]::new(" "[System.Windows.Automation.AutomationElement]" "::ControlTypeProperty," "[System.Windows.Automation.ControlType]::Text)," "[System.Windows.Automation.PropertyCondition]::new(" "[System.Windows.Automation.AutomationElement]" "::ControlTypeProperty," "[System.Windows.Automation.ControlType]::Hyperlink));" "$ch=$item.FindAll(" "[System.Windows.Automation.TreeScope]::Children,$tc);" "$names=@();" "foreach($c in $ch){" "$n=$c.Current.Name;" "if($n -and $n.Length -ge 1){$names+=$n}};" "if($names.Count -ge 2){" "'{0}|||{1}' -f $names[0],$names[1]" "}}}" ) stdout, _ = run_powershell(ps, timeout=15) songs = [] for ln in stdout.split("\n"): ln = ln.strip() if "|||" not in ln: continue parts = ln.split("|||", 1) if len(parts) == 2: songs.append((parts[0].strip(), parts[1].strip())) return songs def _scroll_spotify(self, direction=-1, amount=5): """Scroll the embedded Spotify window using WM_MOUSEWHEEL.""" if not self.spotify_hwnd or not user32.IsWindow(self.spotify_hwnd): return rect = wintypes.RECT() user32.GetWindowRect(self.spotify_hwnd, ctypes.byref(rect)) cx = (rect.left + rect.right) // 2 cy = (rect.top + rect.bottom) // 2 lparam = (cy << 16) | (cx & 0xFFFF) delta = direction * amount * WHEEL_DELTA wparam = ctypes.c_int32(delta << 16).value user32.PostMessageW(self.spotify_hwnd, WM_MOUSEWHEEL, wparam, lparam) def _get_playlist_songs(self, scroll=True): """Collect all songs by scrolling through the Spotify playlist.""" first_pass = self._read_visible_songs() if not scroll or not self.spotify_hwnd: return first_pass all_songs = [] seen_keys = set() for artist, title in first_pass: key = self._song_key(artist, title) if key not in seen_keys: seen_keys.add(key) all_songs.append((artist, title)) self._scroll_spotify(direction=1, amount=50) time.sleep(0.8) no_new_rounds = 0 max_scrolls = 40 for _scroll_i in range(max_scrolls): visible = self._read_visible_songs() new_count = 0 for artist, title in visible: key = self._song_key(artist, title) if key not in seen_keys: seen_keys.add(key) all_songs.append((artist, title)) new_count += 1 if new_count == 0: no_new_rounds += 1 if no_new_rounds >= 3: break else: no_new_rounds = 0 self._scroll_spotify(direction=-1, amount=5) time.sleep(0.6) cached = self._cache.get("all_songs", []) for s in cached: key = self._song_key(s["artist"], s["title"]) if key not in seen_keys: seen_keys.add(key) all_songs.append((s["artist"], s["title"])) for artist, title in all_songs: self._cache_song(artist, title) if all_songs: self._save_cache() return all_songs def _check_playlist(self): if not self.platform_cfg.get("process"): return self.check_btn.configure( state="disabled", text="\u23f3 Scrolle Playlist\u2026") threading.Thread( target=self._check_playlist_async, daemon=True).start() def _check_playlist_async(self): try: folder = (self._custom_folder if self._folder_mode == "custom" and self._custom_folder else self.session_dir) if not folder or not os.path.isdir(folder): folder = self.output_base recorded = {} for f in os.listdir(folder): if not f.lower().endswith(".mp3"): continue path = os.path.join(folder, f) artist, title = "", "" try: tags = mutagen.File(path, easy=True) if tags: artist = (tags.get("artist") or [""])[0] title = (tags.get("title") or [""])[0] except Exception: pass if not artist and not title: name = f[:-4] pts = name.split(" - ", 2) if len(pts) >= 3: artist, title = pts[1], pts[2] elif len(pts) == 2: artist, title = pts[0], pts[1] if artist or title: recorded[self._song_key(artist, title)] = ( artist, title) self.root.after( 0, lambda: self.check_btn.configure( text="\u23f3 Sammle Songs\u2026")) playlist_songs = self._get_playlist_songs(scroll=True) _, total = self._detect_playlist_info() cache_total = self._cache.get("total_tracks", 0) if cache_total > total: total = cache_total self.root.after(0, lambda: self._show_check_result( recorded, playlist_songs, total, folder)) except Exception: self.root.after(0, lambda: self.check_btn.configure( state="normal", text="\U0001f50d Playlist pruefen")) def _show_check_result(self, recorded, playlist_songs, total, folder): self.check_btn.configure( state="normal", text="\U0001f50d Pruefen") win = ctk.CTkToplevel(self.root) win.title("Playlist-Vergleich") win.geometry("520x650") win.resizable(True, True) win.configure(fg_color=BG) win.transient(self.root) if os.path.exists(self._ico_path): win.after(200, lambda: win.iconbitmap(self._ico_path)) win.after(300, lambda: self._set_toplevel_icon(win)) pad = {"padx": 18} ctk.CTkLabel(win, text="\U0001f50d Playlist-Vergleich", font=ctk.CTkFont(size=18, weight="bold"), text_color=TXT).pack(**pad, pady=(16, 4), anchor="w") folder_short = folder if len(folder_short) > 55: folder_short = "\u2026" + folder_short[-52:] ctk.CTkLabel(win, text=f"Ordner: {folder_short}", font=ctk.CTkFont(size=10), text_color=DIM ).pack(**pad, anchor="w", pady=(0, 8)) rec_count = len(recorded) missing_songs = [] found_songs = [] if playlist_songs: for artist, title in playlist_songs: key = self._song_key(artist, title) if key in recorded: found_songs.append((artist, title)) else: missing_songs.append((artist, title)) if total > 0: missing_num = total - rec_count summary = f"{rec_count} von {total} Songs aufgenommen" else: missing_num = len(missing_songs) summary = f"{rec_count} Songs aufgenommen" all_done = (total > 0 and rec_count >= total) if all_done: ctk.CTkLabel(win, text="\u2705 Alle Songs aufgenommen!", font=ctk.CTkFont(size=16, weight="bold"), text_color=GREEN).pack(**pad, anchor="w", pady=(0, 2)) ctk.CTkLabel(win, text=summary, font=ctk.CTkFont(size=12), text_color=TXT2 ).pack(**pad, anchor="w") else: ctk.CTkLabel(win, text=summary, font=ctk.CTkFont(size=14, weight="bold"), text_color=TXT).pack(**pad, anchor="w") if missing_num > 0: ctk.CTkLabel( win, text=f"\u26a0 {missing_num} Songs fehlen noch", font=ctk.CTkFont(size=13, weight="bold"), text_color=ORANGE).pack(**pad, anchor="w", pady=(2, 0)) ctk.CTkFrame(win, height=1, fg_color=BORDER).pack( fill="x", **pad, pady=(10, 6)) if missing_songs: hdr_miss = ctk.CTkFrame(win, fg_color="transparent") hdr_miss.pack(fill="x", **pad) ctk.CTkLabel(hdr_miss, text=f"FEHLENDE SONGS ({len(missing_songs)})", font=ctk.CTkFont(size=9, weight="bold"), text_color=ORANGE).pack(side="left") tb_miss = ctk.CTkTextbox( win, font=ctk.CTkFont(size=11), fg_color="#1a0a0a", text_color="#e88", border_width=1, border_color="#331111", corner_radius=8, state="disabled", activate_scrollbars=True, height=160) tb_miss.pack(fill="both", expand=True, **pad, pady=(4, 6)) tb_miss.configure(state="normal") for i, (artist, title) in enumerate(missing_songs, 1): tb_miss.insert("end", f" {i:2d}. {artist} \u2013 {title}\n") tb_miss.configure(state="disabled") ctk.CTkFrame(win, height=1, fg_color=BORDER).pack( fill="x", **pad, pady=(4, 6)) if found_songs or (playlist_songs and not missing_songs): show_list = found_songs if found_songs else [] if show_list: hdr_ok = ctk.CTkFrame(win, fg_color="transparent") hdr_ok.pack(fill="x", **pad) ctk.CTkLabel(hdr_ok, text=f"AUFGENOMMEN ({len(show_list)})", font=ctk.CTkFont(size=9, weight="bold"), text_color=GREEN).pack(side="left") tb_ok = ctk.CTkTextbox( win, font=ctk.CTkFont(size=11), fg_color=CARD, text_color=TXT2, border_width=0, corner_radius=8, state="disabled", activate_scrollbars=True, height=120) tb_ok.pack(fill="both", expand=True, **pad, pady=(4, 6)) tb_ok.configure(state="normal") for artist, title in show_list: tb_ok.insert( "end", f" \u2713 {artist} \u2013 {title}\n") tb_ok.configure(state="disabled") if not playlist_songs: ctk.CTkLabel(win, text="Scrolle in Spotify durch die Playlist\n" "und pruefe erneut, um mehr Songs zu sehen.", font=ctk.CTkFont(size=11), text_color=DIM, justify="center").pack(**pad, pady=(20, 10)) ctk.CTkButton( win, text="Schliessen", width=120, height=34, corner_radius=12, font=ctk.CTkFont(size=13, weight="bold"), fg_color=GREEN, hover_color=GREEN_H, command=win.destroy ).pack(pady=(8, 14)) def _stop(self, finished=False): self.active = False self.pause_since = None self._allow_sleep() with self._lock: frames = self.recorder.harvest_frames() if frames and self.current_track and self.track_number > 0: self._save(frames, self.current_track, self.track_number) self.recorder.stop() self.current_track = None self.rec_start = None self.rec_btn.configure(text="\u23fa AUFNAHME STARTEN", fg_color=GREEN, hover_color=GREEN_H) self.pl_entry.configure(state="normal") n = len(self.songs) if self.playlist_total > 0: info = f"{self.track_number}/{self.playlist_total} Songs" else: info = f"{n} Songs aufgenommen" not_complete = (self.playlist_total > 0 and self.track_number < self.playlist_total) should_restart = not_complete and not self._user_stopped if should_restart and self._restart_fails < 5: self._set_status(f"{info} \u2013 NEUSTART\u2026", ORANGE) self._restart_countdown(5) elif finished: self._set_status(f"FERTIG \u2013 {info}", GREEN) else: self._set_status( f"GESTOPPT \u2013 {info}" if n else "BEREIT", GREEN if n else DIM) self._user_stopped = False self.level_bar.set(0) def _restart_countdown(self, secs): """Count down and then auto-restart recording.""" if secs <= 0: self._auto_restart() return if self._user_stopped or self.active: self._restart_after_id = None return info = f"{self.track_number}/{self.playlist_total}" self._set_status( f"{info} \u2013 NEUSTART IN {secs}s\u2026", ORANGE) self._restart_after_id = self.root.after( 1000, lambda: self._restart_countdown(secs - 1)) def _auto_restart(self): """Automatically restart recording to continue the playlist.""" self._restart_after_id = None if self.active or self._user_stopped: return if (self.playlist_total > 0 and self.track_number < self.playlist_total): prev_count = self.track_number self._start() self.root.after( 15000, lambda p=prev_count: self._check_restart_progress(p)) def _check_restart_progress(self, prev_count): """Increment fail counter if no new songs were recorded.""" if self.track_number <= prev_count and self.active: self._restart_fails += 1 else: self._restart_fails = 0 # ── track monitoring ─────────────────────────────────────── def _tick_monitor(self): try: self._tick_monitor_inner() except Exception: try: tb = traceback.format_exc() log = os.path.join( os.path.dirname(os.path.abspath(__file__)), "recorder_errors.log") with open(log, "a", encoding="utf-8") as f: f.write(f"\n[{datetime.datetime.now()}] " f"_tick_monitor_inner:\n{tb}\n") except Exception: pass self.root.after(500, self._tick_monitor) def _tick_monitor_inner(self): sep = self.platform_cfg.get("sep") pause_titles = self.platform_cfg.get("pause", set()) if self.spotify_hwnd and user32.IsWindow(self.spotify_hwnd): try: title = get_window_title(self.spotify_hwnd) except Exception: title = "" if not title: self._empty_title_count += 1 if self._empty_title_count >= 20: self._empty_title_count = 0 proc = self.platform_cfg.get("process") if proc: new_hwnd = find_app_window(proc) if new_hwnd and new_hwnd != self.spotify_hwnd: self.spotify_hwnd = new_hwnd self.root.after(100, lambda h=new_hwnd: self._embed(h)) if (self.active and self.current_track and self.rec_start and time.time() - self.rec_start > 600): with self._lock: frames = self.recorder.harvest_frames() if frames: num = self.track_number trk = dict(self.current_track) threading.Thread( target=self._save, args=(frames, trk, num), daemon=True).start() self.current_track = None self._set_status( "\u26a0 TITEL NICHT ERKANNT", ORANGE) return self._empty_title_count = 0 if sep and sep in title: self.ad_playing = False if not self.is_playing: self.is_playing = True self.pause_since = None if self.active: self.recorder.resume() if self.current_track is not None: self._set_status("AUFNAHME", GREEN) elif (self._auto_record_ready and self.auto_rec_var.get()): self._start() if not self._auto_record_ready: self._auto_record_ready = True if title != self.last_title and self.active: self._handle_track_change(title) elif title != self.last_title: parts = title.split(sep, 1) if len(parts) >= 2: self._cache_song( parts[0].strip(), parts[1].strip()) self.artist_lbl.configure(text=parts[0].strip()) self.title_lbl.configure( text=parts[1].strip() if len(parts) >= 2 else "") elif (self.active and self.current_track is None and title == self.last_title): parts = title.split(sep, 1) artist = parts[0].strip() song = parts[1].strip() key = self._song_key(artist, song) if key not in self.recorded_songs: self.track_number += 1 self.current_track = { "artist": artist, "title": song} self._update_track_ui() self._set_status("AUFNAHME", GREEN) else: self.recorder.harvest_frames() elif (self.active and self.current_track is not None and title == self.last_title and self.rec_start and time.time() - self.rec_start > 600): with self._lock: frames = self.recorder.harvest_frames() if frames: num = self.track_number trk = dict(self.current_track) threading.Thread( target=self._save, args=(frames, trk, num), daemon=True).start() self.current_track = None self.rec_start = time.time() self._set_status( "\u26a0 TRACK ZU LANG \u2013 GESPEICHERT", ORANGE) self.last_title = title else: if not self._auto_record_ready: self._auto_record_ready = True if title.lower().strip() in pause_titles: self.ad_playing = False if self.is_playing: self.is_playing = False if self.active: self.recorder.pause() if self.current_track is not None: self.pause_since = time.time() self._set_status("PAUSIERT", ORANGE) else: if not self.ad_playing: self.ad_playing = True if self.active and self.current_track \ and self.track_number > 0: with self._lock: frames = self.recorder.harvest_frames() if frames: num = self.track_number trk = dict(self.current_track) threading.Thread( target=self._save, args=(frames, trk, num), daemon=True).start() self.current_track = None if self.active: self._set_status("\U0001f507 WERBUNG", ORANGE) self.artist_lbl.configure( text="Werbung erkannt") self.title_lbl.configure( text="wird nicht aufgenommen") self.tnum_lbl.configure(text="") if self.active and self.current_track is None: self.recorder.harvest_frames() self.pause_since = None if (self.active and self.pause_since and self.auto_stop_secs > 0): all_done = (self.playlist_total > 0 and self.track_number >= self.playlist_total) timeout = self.auto_stop_secs if not all_done and self.track_number > 0: timeout = max(timeout, 300) elapsed = time.time() - self.pause_since remaining = timeout - elapsed if remaining <= 0: self._stop(finished=True) else: if all_done: lbl = f"Fertig! Stop {int(remaining)}s" elif self.playlist_total > 0: lbl = (f"{self.track_number}/{self.playlist_total}" f" \u23f8 {int(remaining)}s") else: lbl = f"Stop {int(remaining)}s" self.time_lbl.configure( text=lbl, text_color=ORANGE) elif not self.platform_cfg.get("process"): if self.active: lvl = self.recorder.get_level() if lvl < self._silence_threshold: if self._silence_start is None: self._silence_start = time.time() elif (time.time() - self._silence_start >= self._min_silence_secs and self.current_track): with self._lock: frames = self.recorder.harvest_frames() if frames: num = self.track_number trk = dict(self.current_track) threading.Thread( target=self._save, args=(frames, trk, num), daemon=True).start() self.track_number += 1 self.current_track = { "artist": self.platform_name, "title": f"Track {self.track_number:02d}", } self._update_track_ui() self.rec_start = time.time() self._silence_start = None self._set_status("NEUER TRACK", GREEN) else: self._silence_start = None def _handle_track_change(self, new_title): sep = self.platform_cfg.get("sep", " - ") parts = new_title.split(sep, 1) if len(parts) < 2: return new_artist, new_song = parts[0].strip(), parts[1].strip() self._cache_song(new_artist, new_song) key = self._song_key(new_artist, new_song) if key in self.recorded_songs: with self._lock: self.recorder.harvest_frames() self.current_track = None self.pause_since = None self.artist_lbl.configure(text=new_artist) self.title_lbl.configure(text=f"\u2714 {new_song}") self.tnum_lbl.configure(text="BEREITS AUFGENOMMEN") self._set_status( f"\u23ed SKIP {self._skip_count}/{self._max_skips}", ORANGE) self.rec_start = time.time() self._skip_track() return self._skip_count = 0 new_track = {"artist": new_artist, "title": new_song} with self._lock: frames = self.recorder.harvest_frames() if frames and self.current_track and self.track_number > 0: num = self.track_number trk = dict(self.current_track) threading.Thread( target=self._save, args=(frames, trk, num), daemon=True ).start() self.track_number += 1 self.current_track = new_track self.rec_start = time.time() self._update_track_ui() self._set_status("AUFNAHME", GREEN) # ── duplicate detection ───────────────────────────────────── @staticmethod def _song_key(artist, title): return f"{artist}\n{title}".lower().strip() def _scan_existing_songs(self): """Scan session directory for already-recorded songs via ID3 tags. Songs shorter than MIN_SONG_DURATION are considered incomplete and stored in self._incomplete_songs for re-recording. """ found = set() self._incomplete_songs = {} if not self.session_dir or not os.path.isdir(self.session_dir): return found for f in os.listdir(self.session_dir): if not f.lower().endswith(".mp3"): continue fpath = os.path.join(self.session_dir, f) artist, title, duration = "", "", 0 try: tags = mutagen.File(fpath, easy=True) if tags: artist = (tags.get("artist") or [""])[0] title = (tags.get("title") or [""])[0] if hasattr(tags, "info") and tags.info: duration = getattr(tags.info, "length", 0) except Exception: pass if not artist and not title: name = f[:-4] parts = name.split(" - ", 2) if len(parts) >= 3: artist, title = parts[1], parts[2] elif len(parts) == 2: artist, title = parts[0], parts[1] if not artist and not title: continue key = self._song_key(artist, title) if duration > 0 and duration < MIN_SONG_DURATION: self._incomplete_songs[key] = fpath else: found.add(key) return found def _get_max_track_num(self): """Find the highest track number in session_dir via ID3 tags.""" max_num = 0 if not self.session_dir or not os.path.isdir(self.session_dir): return max_num for f in os.listdir(self.session_dir): if not f.lower().endswith(".mp3"): continue try: tags = mutagen.File(os.path.join(self.session_dir, f), easy=True) if tags: trk = (tags.get("tracknumber") or ["0"])[0] max_num = max(max_num, int(trk.split("/")[0])) continue except Exception: pass parts = f[:-4].split(" - ", 2) if parts: try: max_num = max(max_num, int(parts[0])) except ValueError: pass return max_num # ── playlist cache (Zwischenspeicher) ─────────────────────── def _cache_path(self): if not self.session_dir: return None return os.path.join(self.session_dir, "_playlist_cache.json") def _load_cache(self): path = self._cache_path() if not path or not os.path.exists(path): return {"all_songs": [], "playlist_name": "", "total_tracks": 0} try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) data.setdefault("all_songs", []) data.setdefault("playlist_name", "") data.setdefault("total_tracks", 0) return data except Exception: return {"all_songs": [], "playlist_name": "", "total_tracks": 0} def _save_cache(self): path = self._cache_path() if not path: return try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(self._cache, f, ensure_ascii=False, indent=2) except Exception: pass def _cache_song(self, artist, title): """Add a discovered song to the cache if not already present.""" key = self._song_key(artist, title) if key not in self._cached_song_keys: self._cached_song_keys.add(key) self._cache.setdefault("all_songs", []).append( {"artist": artist, "title": title}) self._cache["last_updated"] = ( datetime.datetime.now().isoformat()) # ── save ─────────────────────────────────────────────────── def _save(self, frames, track, number): try: artist = track.get("artist", "") title = track.get("title", "") if not artist and not title: return key = self._song_key(artist, title) if hasattr(self, "_incomplete_songs") and key in self._incomplete_songs: old_path = self._incomplete_songs.pop(key) try: os.remove(old_path) except Exception: pass safe = self._fn_format.format( nr=f"{number:02d}", artist=artist or "Unbekannt", titel=title or "Unbekannt") for ch in '<>:"/\\|?*': safe = safe.replace(ch, "_") path = os.path.join(self.session_dir, f"{safe}.mp3") n = 1 base = path[:-4] while os.path.exists(path): path = f"{base} ({n}).mp3" n += 1 ok = self.recorder.save_mp3( frames, path, title=title, artist=artist, bitrate=self.bitrate, album=self.playlist_name, track_num=number, ) if ok: self.recorded_songs.add(self._song_key(artist, title)) self._cache_song(artist, title) self._save_cache() name = os.path.basename(path) self.songs.append(name) self.root.after(0, lambda: self._add_song(name)) self.root.after(0, self._refresh_total_stats) except Exception: try: tb = traceback.format_exc() log = os.path.join( os.path.dirname(os.path.abspath(__file__)), "recorder_errors.log") with open(log, "a", encoding="utf-8") as f: f.write(f"\n[{datetime.datetime.now()}] " f"_save({track}, #{number}):\n{tb}\n") self.root.after( 0, lambda: self._set_status( "\u26a0 SPEICHERN FEHLGESCHLAGEN", RED)) except Exception: pass def _add_song(self, name): self.song_box.configure(state="normal") self.song_box.insert("end", f"\u2713 {name}\n") self.song_box.see("end") self.song_box.configure(state="disabled") if self.playlist_total > 0: self.cnt_lbl.configure( text=f"{self.track_number} / {self.playlist_total} Songs") else: self.cnt_lbl.configure(text=f"{len(self.songs)} Songs") # ── sleep prevention ─────────────────────────────────────── @staticmethod def _prevent_sleep(): kernel32.SetThreadExecutionState( ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_AWAYMODE_REQUIRED) @staticmethod def _allow_sleep(): kernel32.SetThreadExecutionState(ES_CONTINUOUS) # ── UI helpers ───────────────────────────────────────────── def _update_track_ui(self): if self.current_track: self.artist_lbl.configure(text=self.current_track["artist"]) self.title_lbl.configure(text=self.current_track["title"]) if self.playlist_total > 0: self.tnum_lbl.configure( text=f"TRACK #{self.track_number:02d}" f" / {self.playlist_total}") else: self.tnum_lbl.configure( text=f"TRACK #{self.track_number:02d}") def _set_status(self, text, color): self.stat_lbl.configure(text=text, text_color=color) self.dot_lbl.configure(text_color=color) def _tick_timer(self): if self.active and self.rec_start: s = int(time.time() - self.rec_start) m, s = divmod(s, 60) self.time_lbl.configure(text=f"{m:02d}:{s:02d}", text_color=GREEN) elif not self.active: self.time_lbl.configure(text="", text_color=DIM) self.root.after(500, self._tick_timer) def _tick_level(self): if self.active: lvl = self.recorder.get_level() smooth = self.level_bar.get() * 0.35 + lvl * 0.65 self.level_bar.set(smooth) self.level_bar.configure( progress_color=RED if lvl > 0.85 else GREEN) else: cur = self.level_bar.get() if cur > 0.01: self.level_bar.set(cur * 0.5) else: self.level_bar.set(0) self.root.after(80, self._tick_level) def _tick_pulse(self): if self.active: self._pulse_on = not self._pulse_on self.dot_lbl.configure( text_color=GREEN if self._pulse_on else "#3E4D1E") self.root.after(650, self._tick_pulse) def _tick_alive(self): proc = self.platform_cfg.get("process") if self.spotify_hwnd: if user32.IsWindow(self.spotify_hwnd): self._alive_fails = 0 else: self._alive_fails += 1 new_hwnd = find_app_window(proc) if proc else None if new_hwnd: self.spotify_hwnd = new_hwnd self._alive_fails = 0 else: not_done = (self.playlist_total > 0 and self.track_number < self.playlist_total) threshold = 30 if not_done else 15 if self._alive_fails >= threshold: self.spotify_hwnd = None self._update_sp_status(False) name = self.platform_name self.embed_msg.configure( text=f"\u26a0 {name} wurde geschlossen.\n\n" f"Starte {name} neu und\n" "starte die App erneut.") self.embed_msg.place(relx=0.5, rely=0.5, anchor="center") if self.active: self._stop() if self.active and self.recorder._stream: try: if not self.recorder._stream.is_active(): self.recorder.stop() recovered = False for attempt in range(3): try: time.sleep(0.5 * (attempt + 1)) self.recorder.start() self._prevent_sleep() recovered = True break except Exception: pass if not recovered: self._set_status( "\u26a0 AUDIO-STREAM VERLOREN", RED) try: log = os.path.join( os.path.dirname( os.path.abspath(__file__)), "recorder_errors.log") with open(log, "a", encoding="utf-8") as f: f.write( f"\n[{datetime.datetime.now()}] " "WASAPI stream lost, " "recovery failed after 3 attempts\n") except Exception: pass self._stop() except Exception: pass self.root.after(3000, self._tick_alive) # ── statistics ───────────────────────────────────────────── def _count_total_mp3s(self): """Count all MP3 files recursively in output_base.""" total = 0 for root, _dirs, files in os.walk(self.output_base): for f in files: if f.lower().endswith(".mp3"): total += 1 return total def _refresh_total_stats(self): n = self._count_total_mp3s() folders = 0 try: for entry in os.scandir(self.output_base): if entry.is_dir(): folders += 1 except OSError: pass self.total_lbl.configure( text=f"\U0001f4ca Gesamt: {n} Songs in {folders} Playlists") # ── settings ─────────────────────────────────────────────── def _choose_folder(self): init = self._custom_folder or self.output_base p = filedialog.askdirectory(initialdir=init) if not p: return if self._folder_mode == "custom": self._custom_folder = p folder_name = os.path.basename(p) self.pl_entry.delete(0, "end") self.pl_entry.insert(0, folder_name) self._last_suggestion = folder_name else: self.output_base = p self._lbl_set(self.folder_lbl, p) self._refresh_total_stats() def _open_folder(self): if self._folder_mode == "custom" and self._custom_folder: folder = self._custom_folder elif self.session_dir and os.path.isdir(self.session_dir): folder = self.session_dir else: folder = self.output_base os.startfile(folder) def _set_bitrate(self, val): self.bitrate = int(val.split()[0]) def _set_auto_stop(self, val): if val == "Aus": self.auto_stop_secs = 0 else: self.auto_stop_secs = int(val.split()[0]) # ── cleanup ──────────────────────────────────────────────── def _load_hicons(self): """Load small (16x16) and big (32x32) HICON handles via Win32.""" try: load_img = ctypes.windll.user32.LoadImageW load_img.restype = wintypes.HANDLE ico = self._ico_path self._hicon_small = load_img( 0, ico, IMAGE_ICON, 16, 16, LR_LOADFROMFILE) self._hicon_big = load_img( 0, ico, IMAGE_ICON, 32, 32, LR_LOADFROMFILE) except Exception: self._hicon_small = None self._hicon_big = None def _set_toplevel_icon(self, win): """Apply WM_SETICON to a CTkToplevel window.""" try: hwnd = int(win.wm_frame(), 16) self._apply_icon_to_hwnd(hwnd) except Exception: pass def _apply_initial_icon(self): """Called once after window is fully created.""" try: self.root.iconbitmap(self._ico_path) except Exception: pass hwnd = self._get_tk_hwnd() if hwnd: self._apply_icon_to_hwnd(hwnd) def _apply_icon_to_hwnd(self, hwnd): """Send WM_SETICON for both small and big icons.""" try: send = user32.SendMessageW if self._hicon_small: send(hwnd, WM_SETICON, ICON_SMALL, self._hicon_small) if self._hicon_big: send(hwnd, WM_SETICON, ICON_BIG, self._hicon_big) except Exception: pass def _get_tk_hwnd(self): """Get the native HWND of the root Tk window.""" try: return int(self.root.wm_frame(), 16) except Exception: try: return user32.GetParent(int(self.root.wm_frame(), 16)) except Exception: return None def _tick_icon(self): try: if os.path.exists(self._ico_path): self.root.iconbitmap(self._ico_path) hwnd = self._get_tk_hwnd() if hwnd: self._apply_icon_to_hwnd(hwnd) except Exception: pass self.root.after(5000, self._tick_icon) def _on_close(self): if not self._app_initialized: self.root.destroy() return self._user_stopped = True if self._restart_after_id: self.root.after_cancel(self._restart_after_id) self._restart_after_id = None if self.active: self._stop() self._allow_sleep() self._restore_spotify() self.recorder.cleanup() self.root.destroy() def run(self): self.root.mainloop() # ═══════════════════════════════════════════════════════════════ # Entry point # ═══════════════════════════════════════════════════════════════ def main(): app = SpotifyRecorderApp() app.run() if __name__ == "__main__": try: main() except Exception: import tkinter as _tk from tkinter import messagebox as _mb _r = _tk.Tk() _r.withdraw() _mb.showerror( "Surovy's Music Recorder", f"Fehler:\n\n{traceback.format_exc()}\n\n" f"pip install -r requirements.txt", ) _r.destroy() sys.exit(1)