""" 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 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 # ── 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 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, }, "TuneIn / Radio": { "process": None, "sep": None, "pause": set(), "launch_dirs": [], "cmd": None, }, "SoundCloud": { "process": None, "sep": None, "pause": set(), "launch_dirs": [], "cmd": None, }, "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 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) ico = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app.ico") if os.path.exists(ico): self.root.iconbitmap(ico) self.root.after(200, lambda: self.root.iconbitmap(ico)) self.root.protocol("WM_DELETE_WINDOW", self._on_close) 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._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) # 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)) self.top_folder_lbl = ctk.CTkLabel( panel, text=self.output_base, font=ctk.CTkFont(size=11), text_color=GREEN, wraplength=PANEL_W - 50, anchor="w") self.top_folder_lbl.pack(**pad, anchor="w", pady=(4, 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") # 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.fm_var = ctk.StringVar(value="Automatisch") 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.folder_lbl = None self.folder_hint_lbl = None 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).") 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") 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)) 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 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.top_folder_lbl, target) 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.top_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") else: self._folder_mode = "auto" self._lbl_set(self.folder_lbl, self.output_base) self._lbl_set(self.top_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._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("420x520") win.resizable(False, False) win.configure(fg_color=BG) win.transient(self.root) win.grab_set() 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") # -- Zielordner -- ctk.CTkLabel(win, text="ZIELORDNER", font=ctk.CTkFont(size=9, weight="bold"), text_color=DIM).pack(**pad, anchor="w") fm_row = ctk.CTkFrame(win, fg_color="transparent") fm_row.pack(fill="x", **pad, pady=(6, 0)) ctk.CTkLabel(fm_row, text="Modus", font=ctk.CTkFont(size=12), text_color=TXT2).pack(side="left") ctk.CTkOptionMenu( fm_row, values=["Automatisch", "Eigener Ordner"], variable=self.fm_var, width=160, height=28, 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(win, fg_color="transparent") fr.pack(fill="x", **pad, pady=(8, 0)) ctk.CTkButton(fr, text="Aendern", width=70, height=28, 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=85, height=28, 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.folder_lbl = ctk.CTkLabel( win, text=(self._custom_folder if self._folder_mode == "custom" else self.output_base), font=ctk.CTkFont(size=11), text_color=GREEN, wraplength=370, anchor="w") self.folder_lbl.pack(**pad, anchor="w", pady=(4, 0)) self.folder_hint_lbl = ctk.CTkLabel( win, text=("Songs direkt in diesen Ordner (kein Unterordner)" if self._folder_mode == "custom" else "Erstellt Unterordner pro Playlist"), font=ctk.CTkFont(size=9), text_color="#444") self.folder_hint_lbl.pack(**pad, anchor="w") ctk.CTkFrame(win, height=1, fg_color=BORDER).pack(fill="x", **pad, pady=(12, 12)) # -- 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 user_name = self.pl_entry.get().strip() self.rec_btn.configure(state="disabled") self._set_status("WIRD GESTARTET\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") if has_app and self.spotify_hwnd: detected_name, total = self._detect_playlist_info() else: detected_name, total = None, 0 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: 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.""" dialog = ctk.CTkInputDialog( text="Kein Playlist-Name erkannt.\n\n" f"Vorschlag: {suggestion}\n\n" "Ordnername eingeben\n" "(leer = Vorschlag uebernehmen):", title="Surovy's Music Recorder \u2013 Ordnername", fg_color=CARD, button_fg_color=GREEN, button_hover_color=GREEN_H) result = dialog.get_input() if result is None: self.rec_btn.configure(state="normal") self._set_status("BEREIT", DIM) return name = result.strip() or suggestion has_app = self.platform_cfg.get("process") if has_app and self.spotify_hwnd: try: title = get_window_title(self.spotify_hwnd) except Exception: title = "" sep = self.platform_cfg.get("sep") if not title or (sep and sep not in title): self._send_play() self.root.after( 2000, lambda: self._start_finish(name, total)) return self._start_finish(name, 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.top_folder_lbl, self.session_dir) 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() 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 skipping = False sep = self.platform_cfg.get("sep") has_app = self.platform_cfg.get("process") 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 = True 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") 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 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 _detect_playlist_info(self): """Detect playlist name AND track count in a single call.""" if not self.spotify_hwnd: return None, 0 try: hwnd_val = int(self.spotify_hwnd) except (TypeError, ValueError): return None, 0 ps = ( "$ErrorActionPreference='SilentlyContinue';" "Add-Type -AssemblyName UIAutomationClient;" "Add-Type -AssemblyName UIAutomationTypes;" "$r=[System.Windows.Automation.AutomationElement]" f"::FromHandle([IntPtr]::new({hwnd_val}));" "if($r){" "$h=[System.Windows.Automation.PropertyCondition]::new(" "[System.Windows.Automation.AutomationElement]" "::ControlTypeProperty," "[System.Windows.Automation.ControlType]::Hyperlink);" "$t=[System.Windows.Automation.PropertyCondition]::new(" "[System.Windows.Automation.AutomationElement]" "::ControlTypeProperty," "[System.Windows.Automation.ControlType]::Text);" "$g=[System.Windows.Automation.PropertyCondition]::new(" "[System.Windows.Automation.AutomationElement]" "::ControlTypeProperty," "[System.Windows.Automation.ControlType]::Button);" "$c=[System.Windows.Automation.OrCondition]::new($h,$t,$g);" "$ls=$r.FindAll(" "[System.Windows.Automation.TreeScope]::Descendants,$c);" "foreach($l in $ls){" "$n=$l.Current.Name;" "$b=$l.Current.BoundingRectangle;" "if($n -and $n.Length -ge 2 -and $b.Width -gt 0){" "'{0}|{1}|{2}' -f [int]$b.Height,[int]$b.Bottom,$n" "}}}" ) try: r = subprocess.run( ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps], capture_output=True, text=True, timeout=12, creationflags=0x08000000, ) raw = [ln.strip() for ln in r.stdout.split("\n") if ln.strip() and "|" in ln] except Exception: return None, 0 if not raw: return None, 0 entries = [] track_count = 0 count_re = re.compile( r'(\d+)\s*(songs?|titel|tracks?|titres?|morceaux' r'|canciones|brani)', 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", } 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 return playlist_name, track_count 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: 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: return 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) self.artist_lbl.configure(text=parts[0].strip()) self.title_lbl.configure(text=parts[1].strip()) 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() 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 self.playlist_total > 0 and not all_done: 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) new_artist, new_song = parts[0].strip(), parts[1].strip() 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.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("\u23ed WARTE AUF NEUEN SONG", ORANGE) self.rec_start = time.time() return 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.""" found = set() 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 try: tags = mutagen.File(os.path.join(self.session_dir, f), easy=True) if tags: artist = (tags.get("artist") or [""])[0] title = (tags.get("title") or [""])[0] if artist or title: found.add(self._song_key(artist, title)) continue except Exception: pass name = f[:-4] parts = name.split(" - ", 2) if len(parts) >= 3: found.add(self._song_key(parts[1], parts[2])) 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 # ── save ─────────────────────────────────────────────────── def _save(self, frames, track, number): artist = track.get("artist", "") title = track.get("title", "") if not artist and not title: return 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)) 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) 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 = 10 if not_done else 4 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() try: self.recorder.start() self._prevent_sleep() except Exception: 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 else: self.output_base = p self._lbl_set(self.folder_lbl, p) self._lbl_set(self.top_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 _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)