""" Spotify Recorder – Embeds the Spotify desktop app (already logged in) directly into the application window. No web player, no re-login. Records each song 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 from audio import AudioRecorder # ── Win32 constants ──────────────────────────────────────────── user32 = ctypes.windll.user32 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 # ── Colors ───────────────────────────────────────────────────── BG = "#0b0b0b" CARD = "#151515" BORDER = "#1e1e1e" GREEN = "#1DB954" GREEN_H = "#1ed760" RED = "#e74c3c" RED_H = "#ff6b6b" ORANGE = "#f59e0b" TXT = "#ffffff" TXT2 = "#b3b3b3" DIM = "#555555" PANEL_W = 330 # ═══════════════════════════════════════════════════════════════ # Spotify window helper functions # ═══════════════════════════════════════════════════════════════ def get_spotify_pids(): pids = set() for proc in psutil.process_iter(["pid", "name"]): try: if (proc.info["name"] or "").lower() == "spotify.exe": pids.add(proc.info["pid"]) except (psutil.NoSuchProcess, psutil.AccessDenied): pass return pids def find_spotify_window(): pids = get_spotify_pids() 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_spotify(): paths = [ os.path.join(os.getenv("APPDATA", ""), "Spotify", "Spotify.exe"), os.path.join( os.getenv("LOCALAPPDATA", ""), "Microsoft", "WindowsApps", "Spotify.exe", ), ] for p in paths: if os.path.exists(p): subprocess.Popen([p]) return True try: subprocess.Popen(["spotify"]) return True except FileNotFoundError: pass return False # ═══════════════════════════════════════════════════════════════ # Main application # ═══════════════════════════════════════════════════════════════ class SpotifyRecorderApp: def __init__(self): 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("Spotify Recorder") self.root.geometry("1400x880") self.root.minsize(1000, 620) self.root.configure(fg_color=BG) self.root.protocol("WM_DELETE_WINDOW", self._on_close) # state 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.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 = 30 self.pause_since = None self._pulse_on = True self._lock = threading.Lock() self._build_controls() self._build_embed_area() threading.Thread(target=self._attach_spotify, daemon=True).start() self._tick_monitor() self._tick_timer() self._tick_level() self._tick_pulse() self._tick_alive() # ── 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)) 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="\u266b", font=ctk.CTkFont(size=22, 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="Spotify Recorder", font=ctk.CTkFont(size=18, weight="bold"), text_color=TXT).pack(anchor="w") ctk.CTkLabel(hcol, text="Desktop Audio Capture", font=ctk.CTkFont(size=10), text_color=DIM).pack(anchor="w") # spotify 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=(16, 0), anchor="w") # separator ctk.CTkFrame(panel, height=1, fg_color=BORDER).pack(fill="x", **pad, pady=12) # 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)) ctk.CTkLabel(panel, text="Leer lassen = automatisch (Datum/Uhrzeit)", font=ctk.CTkFont(size=9), text_color="#444" ).pack(**pad, anchor="w", pady=(2, 0)) # 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 ctk.CTkFrame(panel, height=1, fg_color=BORDER).pack(fill="x", **pad, pady=10) ctk.CTkLabel(panel, text="EINSTELLUNGEN", font=ctk.CTkFont(size=9, weight="bold"), text_color=DIM).pack(**pad, anchor="w") fr = ctk.CTkFrame(panel, fg_color="transparent") fr.pack(fill="x", **pad, pady=(6, 0)) ctk.CTkLabel(fr, text="Ordner", font=ctk.CTkFont(size=12), text_color=TXT2).pack(side="left") ctk.CTkButton(fr, text="Aendern", width=70, height=26, corner_radius=8, font=ctk.CTkFont(size=11), fg_color="#2a2a2a", hover_color="#383838", command=self._choose_folder).pack(side="right") self.folder_lbl = ctk.CTkLabel( panel, text=self.output_base, font=ctk.CTkFont(size=9), text_color=GREEN, wraplength=PANEL_W - 50, anchor="w") self.folder_lbl.pack(**pad, anchor="w", pady=(2, 0)) br_row = ctk.CTkFrame(panel, 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") self.br_var = ctk.StringVar(value="320 kbps") ctk.CTkOptionMenu( br_row, values=["128 kbps", "192 kbps", "256 kbps", "320 kbps"], variable=self.br_var, width=110, height=26, 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(panel, fg_color="transparent") as_row.pack(fill="x", **pad, pady=(8, 4)) ctk.CTkLabel(as_row, text="Auto-Stop", font=ctk.CTkFont(size=12), text_color=TXT2).pack(side="left") self.as_var = ctk.StringVar(value="30 Sek.") ctk.CTkOptionMenu( as_row, values=["15 Sek.", "30 Sek.", "60 Sek.", "120 Sek.", "Aus"], variable=self.as_var, width=110, height=26, 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(panel, text="Stoppt Aufnahme wenn Playlist endet", font=ctk.CTkFont(size=9), text_color="#444" ).pack(**pad, anchor="w", pady=(0, 16)) # ── 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="\u23f3 Spotify wird gesucht\u2026\n\n" "Stelle sicher, dass die Spotify Desktop-App\n" "geoeffnet ist (bereits eingeloggt).", font=ctk.CTkFont(size=15), text_color="#444", justify="center") self.embed_msg.place(relx=0.5, rely=0.5, anchor="center") self.embed_frame.bind("", self._on_embed_resize) # ── Spotify embedding ────────────────────────────────────── def _attach_spotify(self): for _ in range(30): hwnd = find_spotify_window() if hwnd: self.root.after(100, lambda h=hwnd: self._embed(h)) return time.sleep(1) if not get_spotify_pids(): self.root.after(0, lambda: self.embed_msg.configure( text="\u26a0 Spotify nicht gefunden.\n\n" "Starte die Spotify Desktop-App\n" "und klicke dann hier.", )) launched = launch_spotify() if launched: for _ in range(20): hwnd = find_spotify_window() 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): if connected: self.sp_label.configure( text="\u25cf Spotify verbunden", text_color=GREEN) else: self.sp_label.configure( text="\u25cf Spotify nicht verbunden", text_color=RED) # ── recording control ────────────────────────────────────── def _toggle(self): if self.active: self._stop() else: self._start() def _start(self): name = self.pl_entry.get().strip() if not name: name = datetime.datetime.now().strftime("Spotify_%Y-%m-%d_%H-%M") self.pl_entry.delete(0, "end") self.pl_entry.insert(0, name) if not self.spotify_hwnd or not user32.IsWindow(self.spotify_hwnd): self._set_status("SPOTIFY FEHLT", RED) return 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) try: self.recorder.start() except RuntimeError as e: self._set_status(str(e)[:40], RED) return self.active = True self.track_number = 0 self.current_track = None self.songs = [] self.last_title = "" self.pause_since = None title = get_window_title(self.spotify_hwnd) if title and " - " in title: parts = title.split(" - ", 1) self.track_number = 1 self.current_track = {"artist": parts[0].strip(), "title": parts[1].strip()} self.last_title = title self.is_playing = True self._update_track_ui() self.rec_start = time.time() self.rec_btn.configure(text="\u23f9 AUFNAHME STOPPEN", fg_color=RED, hover_color=RED_H) self.pl_entry.configure(state="disabled") self._set_status("AUFNAHME", GREEN) def _stop(self, finished=False): self.active = False self.pause_since = None 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") if finished: self._set_status("PLAYLIST BEENDET", GREEN) else: self._set_status("BEREIT", DIM) self.level_bar.set(0) # ── track monitoring ─────────────────────────────────────── def _tick_monitor(self): if self.spotify_hwnd and user32.IsWindow(self.spotify_hwnd): title = get_window_title(self.spotify_hwnd) if title and " - " in title: if not self.is_playing: self.is_playing = True self.pause_since = None if self.active: self.recorder.resume() self._set_status("AUFNAHME", GREEN) if title != self.last_title and self.active: self._handle_track_change(title) elif title != self.last_title: parts = title.split(" - ", 1) self.artist_lbl.configure(text=parts[0].strip()) self.title_lbl.configure(text=parts[1].strip()) self.last_title = title elif title: if self.is_playing: self.is_playing = False if self.active: self.recorder.pause() self.pause_since = time.time() self._set_status("PAUSIERT", ORANGE) if (self.active and self.pause_since and self.auto_stop_secs > 0): elapsed = time.time() - self.pause_since remaining = self.auto_stop_secs - elapsed if remaining <= 0: self._stop(finished=True) else: self.time_lbl.configure( text=f"Stop {int(remaining)}s", text_color=ORANGE) self.root.after(500, self._tick_monitor) def _handle_track_change(self, new_title): parts = new_title.split(" - ", 1) new_track = {"artist": parts[0].strip(), "title": parts[1].strip()} 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() # ── save ─────────────────────────────────────────────────── def _save(self, frames, track, number): artist = track.get("artist", "") title = track.get("title", "") if not artist and not title: return parts = [f"{number:02d}"] if artist: parts.append(artist) if title: parts.append(title) safe = " - ".join(parts) 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: name = os.path.basename(path) self.songs.append(name) self.root.after(0, lambda: self._add_song(name)) 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") self.cnt_lbl.configure(text=f"{len(self.songs)} Songs") # ── 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"]) 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 "#14532d") self.root.after(650, self._tick_pulse) def _tick_alive(self): if self.spotify_hwnd and not user32.IsWindow(self.spotify_hwnd): self.spotify_hwnd = None self._update_sp_status(False) self.embed_msg.configure( text="\u26a0 Spotify wurde geschlossen.\n\n" "Starte Spotify neu und\n" "starte die App erneut.") self.embed_msg.place(relx=0.5, rely=0.5, anchor="center") if self.active: self._stop() self.root.after(3000, self._tick_alive) # ── settings ─────────────────────────────────────────────── def _choose_folder(self): p = filedialog.askdirectory(initialdir=self.output_base) if p: self.output_base = p self.folder_lbl.configure(text=p) 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 self.active: self._stop() 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( "Spotify Recorder", f"Fehler:\n\n{traceback.format_exc()}\n\n" f"pip install -r requirements.txt", ) _r.destroy() sys.exit(1)