Files
aza/APP/spotify-recorder/main.py
2026-04-16 13:32:32 +02:00

2732 lines
106 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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.root.after(5000, lambda: self._safe_timer(
self._tick_icon, 5000))
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._suggest_done = False
self._container_hwnd = 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.root.after(500, lambda: self._safe_timer(self._tick_timer, 500))
self.root.after(80, lambda: self._safe_timer(self._tick_level, 80))
self.root.after(650, lambda: self._safe_timer(self._tick_pulse, 650))
self.root.after(3000, lambda: self._safe_timer(self._tick_alive, 3000))
self.root.after(4000, lambda: self._safe_timer(
self._tick_suggest, 4000))
# ── 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("<Configure>", 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._container_hwnd = container
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 _refit_embed(self):
"""Re-fit embedded window to frame dimensions (main thread)."""
if self.spotify_hwnd and user32.IsWindow(self.spotify_hwnd):
try:
w = self.embed_frame.winfo_width()
h = self.embed_frame.winfo_height()
user32.MoveWindow(self.spotify_hwnd, 0, 0,
max(w, 400), max(h, 300), True)
except Exception:
pass
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()
def _suggest_async(self):
try:
name, _ = self._detect_playlist_info(allow_unembed=False)
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(
allow_unembed=True)
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("<Return>", lambda e: on_ok())
entry.bind("<Escape>", 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)
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 = existing
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)
key = self._song_key(artist, song)
if key 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.recorded_songs.add(key)
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, allow_unembed=False):
"""Detect playlist name AND track count via UI Automation + cache.
When allow_unembed is True, temporarily removes WS_CHILD from the
embedded Spotify window so that UI Automation can traverse its
accessibility tree, then re-embeds immediately after.
When False, only returns cached data (no visual disruption).
"""
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
if not allow_unembed:
return cached_name or None, cached_total
try:
hwnd_val = int(self.spotify_hwnd)
except (TypeError, ValueError):
return cached_name or None, cached_total
container = getattr(self, '_container_hwnd', 0)
orig_style = getattr(self, 'original_style', None)
ps_tpl = (
"$ErrorActionPreference='SilentlyContinue';"
"Add-Type -AssemblyName UIAutomationClient;"
"Add-Type -AssemblyName UIAutomationTypes;"
"$r=[System.Windows.Automation.AutomationElement]"
"::FromHandle([IntPtr]::new(_HWND_));"
"if($r){"
"$c=[System.Windows.Automation.Condition]::TrueCondition;"
"$ls=$r.FindAll("
"[System.Windows.Automation.TreeScope]::Descendants,$c);"
"$cnt=0;"
"foreach($l in $ls){"
"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++}}}"
)
ps = ps_tpl.replace("_HWND_", str(hwnd_val))
raw = []
stderr_out = ""
unembed_used = False
if container and orig_style is not None:
user32.ShowWindow(hwnd_val, 0)
user32.SetParent(hwnd_val, 0)
user32.SetWindowLongW(hwnd_val, GWL_STYLE, orig_style)
user32.SetWindowPos(
hwnd_val, 0, -4000, -3000, 1400, 900,
SWP_FRAMECHANGED | SWP_NOZORDER)
try:
oleacc = ctypes.windll.oleacc
obj = ctypes.c_void_p()
oleacc.AccessibleObjectFromWindow(
hwnd_val, 0,
ctypes.byref(
(ctypes.c_byte * 16)(
0x61, 0x04, 0xD1, 0x61,
0x6A, 0x28, 0xCE, 0x11,
0xA0, 0x32, 0x08, 0x00,
0x2B, 0x2B, 0x8D, 0x8A)),
ctypes.byref(obj))
except Exception:
pass
time.sleep(2.0)
unembed_used = True
try:
stdout1, stderr1 = run_powershell(ps, timeout=12)
stderr_out = stderr1
raw = [ln.strip() for ln in stdout1.split("\n")
if ln.strip() and "|" in ln]
finally:
user32.SetParent(hwnd_val, container)
style = orig_style
style &= ~WS_CAPTION
style &= ~WS_THICKFRAME
style &= ~WS_POPUP
style |= WS_CHILD
user32.SetWindowLongW(hwnd_val, GWL_STYLE, style)
user32.SetWindowPos(
hwnd_val, 0, 0, 0, 0, 0,
SWP_FRAMECHANGED | SWP_NOMOVE
| SWP_NOSIZE | SWP_NOZORDER)
user32.ShowWindow(hwnd_val, 5)
self.root.after(50, self._refit_embed)
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"hwnd={hwnd_val} unembed={unembed_used} "
f"elements={len(raw)}\n")
if stderr_out:
f.write(f"stderr: {stderr_out}\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")
total_rec = len(self.recorded_songs)
if self.playlist_total > 0:
info = f"{total_rec}/{self.playlist_total} Songs"
else:
info = f"{total_rec} Songs aufgenommen"
not_complete = (self.playlist_total > 0
and total_rec < 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 total_rec else "BEREIT",
GREEN if total_rec 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
total_rec = len(self.recorded_songs)
info = f"{total_rec}/{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
total_rec = len(self.recorded_songs)
if (self.playlist_total > 0
and total_rec < self.playlist_total):
prev_count = total_rec
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."""
total_rec = len(self.recorded_songs)
if total_rec <= 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.recorded_songs.add(key)
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):
total_rec = len(self.recorded_songs)
all_done = (self.playlist_total > 0
and total_rec >= self.playlist_total)
timeout = self.auto_stop_secs
if not all_done and total_rec > 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"{total_rec}/{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
self.recorded_songs.add(key)
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):
a = artist.strip().lower()
t = title.strip().lower()
for ch in '<>:"/\\|?*':
a = a.replace(ch, '_')
t = t.replace(ch, '_')
a = ' '.join(a.split())
t = ' '.join(t.split())
return f"{a}\n{t}"
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 key in self.recorded_songs:
return
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")
if os.path.exists(path):
self.recorded_songs.add(key)
return
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(key)
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")
total_rec = len(self.recorded_songs)
if self.playlist_total > 0:
self.cnt_lbl.configure(
text=f"{total_rec} / {self.playlist_total} Songs")
else:
self.cnt_lbl.configure(text=f"{total_rec} 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)
def _safe_timer(self, callback, interval_ms):
"""Run a timer callback safely; log exceptions, always reschedule."""
try:
callback()
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"{callback.__name__}:\n{tb}\n")
except Exception:
pass
self.root.after(interval_ms, lambda: self._safe_timer(
callback, interval_ms))
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)
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")
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:
total_rec = len(self.recorded_songs)
not_done = (self.playlist_total > 0
and total_rec < 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(6):
try:
wait = min(1.0 * (attempt + 1), 5.0)
time.sleep(wait)
if attempt >= 3:
self.recorder.cleanup()
self.recorder = AudioRecorder()
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 6 attempts\n")
except Exception:
pass
self._stop()
else:
self._set_status("AUFNAHME", GREEN)
except Exception:
pass
# ── 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):
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)
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)