1142 lines
44 KiB
Python
1142 lines
44 KiB
Python
|
|
"""
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
PAUSE_TITLES = {"spotify", "spotify premium", "spotify free"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════
|
|||
|
|
# 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.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._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=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.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="60 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, 6))
|
|||
|
|
|
|||
|
|
# total stats
|
|||
|
|
ctk.CTkFrame(panel, height=1, fg_color=BORDER).pack(fill="x", **pad, pady=(0, 6))
|
|||
|
|
self.total_lbl = ctk.CTkLabel(
|
|||
|
|
panel, text="",
|
|||
|
|
font=ctk.CTkFont(size=10), text_color=DIM)
|
|||
|
|
self.total_lbl.pack(**pad, anchor="w", pady=(0, 10))
|
|||
|
|
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="\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("<Configure>", 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._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 not self.spotify_hwnd or not user32.IsWindow(self.spotify_hwnd):
|
|||
|
|
self._set_status("SPOTIFY FEHLT", RED)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
self._user_stopped = False
|
|||
|
|
user_name = self.pl_entry.get().strip()
|
|||
|
|
self.rec_btn.configure(state="disabled")
|
|||
|
|
self._set_status("WIRD GESTARTET\u2026", GREEN)
|
|||
|
|
threading.Thread(
|
|||
|
|
target=self._start_async, args=(user_name,), daemon=True
|
|||
|
|
).start()
|
|||
|
|
|
|||
|
|
def _start_async(self, user_name):
|
|||
|
|
if not user_name:
|
|||
|
|
detected = self._try_detect_playlist()
|
|||
|
|
name = detected or datetime.datetime.now().strftime(
|
|||
|
|
"Spotify_%Y-%m-%d_%H-%M")
|
|||
|
|
else:
|
|||
|
|
name = user_name
|
|||
|
|
|
|||
|
|
total = self._try_detect_track_count()
|
|||
|
|
|
|||
|
|
title = get_window_title(self.spotify_hwnd)
|
|||
|
|
if not title or " - " not in title:
|
|||
|
|
self._send_play()
|
|||
|
|
time.sleep(2)
|
|||
|
|
|
|||
|
|
self.root.after(0, lambda n=name, t=total: self._start_finish(n, t))
|
|||
|
|
|
|||
|
|
def _start_finish(self, name, total=0):
|
|||
|
|
if not self.spotify_hwnd or not user32.IsWindow(self.spotify_hwnd):
|
|||
|
|
self._set_status("SPOTIFY 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)
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
self.rec_btn.configure(state="normal")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
self.recorded_songs = self._scan_existing_songs()
|
|||
|
|
existing = len(self.recorded_songs)
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
skipping = False
|
|||
|
|
title = get_window_title(self.spotify_hwnd)
|
|||
|
|
if title and " - " in title:
|
|||
|
|
parts = title.split(" - ", 1)
|
|||
|
|
artist, song = parts[0].strip(), parts[1].strip()
|
|||
|
|
self.last_title = title
|
|||
|
|
self.is_playing = True
|
|||
|
|
|
|||
|
|
if self._song_key(artist, song) in self.recorded_songs:
|
|||
|
|
skipping = True
|
|||
|
|
self.artist_lbl.configure(text=artist)
|
|||
|
|
self.title_lbl.configure(
|
|||
|
|
text=f"\u2714 {song}")
|
|||
|
|
self.tnum_lbl.configure(text="BEREITS AUFGENOMMEN")
|
|||
|
|
else:
|
|||
|
|
self.track_number += 1
|
|||
|
|
self.current_track = {"artist": artist, "title": song}
|
|||
|
|
self._update_track_ui()
|
|||
|
|
self.rec_start = time.time()
|
|||
|
|
|
|||
|
|
if self.playlist_total > 0:
|
|||
|
|
self.cnt_lbl.configure(
|
|||
|
|
text=f"{existing} / {self.playlist_total} Songs")
|
|||
|
|
elif existing > 0:
|
|||
|
|
self.cnt_lbl.configure(text=f"{existing} bereits vorhanden")
|
|||
|
|
|
|||
|
|
self.rec_btn.configure(
|
|||
|
|
text="\u23f9 AUFNAHME STOPPEN",
|
|||
|
|
fg_color=RED, hover_color=RED_H, state="normal")
|
|||
|
|
self.pl_entry.configure(state="disabled")
|
|||
|
|
if skipping:
|
|||
|
|
self._set_status("\u23ed WARTE AUF NEUEN SONG", ORANGE)
|
|||
|
|
else:
|
|||
|
|
self._set_status("AUFNAHME", GREEN)
|
|||
|
|
|
|||
|
|
def _send_play(self):
|
|||
|
|
"""Send media play/pause key to start Spotify playback."""
|
|||
|
|
user32.keybd_event(0xB3, 0, 0, 0)
|
|||
|
|
time.sleep(0.05)
|
|||
|
|
user32.keybd_event(0xB3, 0, 2, 0)
|
|||
|
|
|
|||
|
|
def _try_detect_playlist(self):
|
|||
|
|
"""Best-effort: detect playlist name via PowerShell UI Automation."""
|
|||
|
|
if not self.spotify_hwnd:
|
|||
|
|
return None
|
|||
|
|
try:
|
|||
|
|
hwnd_val = int(self.spotify_hwnd)
|
|||
|
|
except (TypeError, ValueError):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
ps = (
|
|||
|
|
"$ErrorActionPreference='SilentlyContinue';"
|
|||
|
|
"Add-Type -AssemblyName UIAutomationClient;"
|
|||
|
|
"Add-Type -AssemblyName UIAutomationTypes;"
|
|||
|
|
"$r=[System.Windows.Automation.AutomationElement]"
|
|||
|
|
f"::FromHandle([IntPtr]::new({hwnd_val}));"
|
|||
|
|
"if($r){"
|
|||
|
|
"$c=[System.Windows.Automation.PropertyCondition]::new("
|
|||
|
|
"[System.Windows.Automation.AutomationElement]"
|
|||
|
|
"::ControlTypeProperty,"
|
|||
|
|
"[System.Windows.Automation.ControlType]::Hyperlink);"
|
|||
|
|
"$ls=$r.FindAll("
|
|||
|
|
"[System.Windows.Automation.TreeScope]::Descendants,$c);"
|
|||
|
|
"foreach($l in $ls){"
|
|||
|
|
"$n=$l.Current.Name;"
|
|||
|
|
"$b=$l.Current.BoundingRectangle;"
|
|||
|
|
"if($n -and $n.Length -ge 2 -and $b.Width -gt 0){"
|
|||
|
|
"'{0}|{1}' -f [int]$b.Bottom,$n"
|
|||
|
|
"}}}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
r = subprocess.run(
|
|||
|
|
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
|||
|
|
"-Command", ps],
|
|||
|
|
capture_output=True, text=True, timeout=8,
|
|||
|
|
creationflags=0x08000000,
|
|||
|
|
)
|
|||
|
|
raw = [ln.strip() for ln in r.stdout.split("\n")
|
|||
|
|
if ln.strip() and "|" in ln]
|
|||
|
|
except Exception:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
if not raw:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
entries = []
|
|||
|
|
for line in raw:
|
|||
|
|
pos, _, nm = line.partition("|")
|
|||
|
|
try:
|
|||
|
|
entries.append((int(pos), nm))
|
|||
|
|
except ValueError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
if not entries:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
title = get_window_title(self.spotify_hwnd)
|
|||
|
|
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",
|
|||
|
|
}
|
|||
|
|
if title and " - " in title:
|
|||
|
|
parts = title.split(" - ", 1)
|
|||
|
|
skip.add(parts[0].strip().lower())
|
|||
|
|
skip.add(parts[1].strip().lower())
|
|||
|
|
|
|||
|
|
entries.sort(key=lambda x: x[0], reverse=True)
|
|||
|
|
|
|||
|
|
for _, nm in entries:
|
|||
|
|
if nm.lower() not in skip and len(nm) >= 2:
|
|||
|
|
return nm
|
|||
|
|
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def _try_detect_track_count(self):
|
|||
|
|
"""Best-effort: detect number of tracks in current playlist/album."""
|
|||
|
|
if not self.spotify_hwnd:
|
|||
|
|
return 0
|
|||
|
|
try:
|
|||
|
|
hwnd_val = int(self.spotify_hwnd)
|
|||
|
|
except (TypeError, ValueError):
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
ps = (
|
|||
|
|
"$ErrorActionPreference='SilentlyContinue';"
|
|||
|
|
"Add-Type -AssemblyName UIAutomationClient;"
|
|||
|
|
"Add-Type -AssemblyName UIAutomationTypes;"
|
|||
|
|
"$r=[System.Windows.Automation.AutomationElement]"
|
|||
|
|
f"::FromHandle([IntPtr]::new({hwnd_val}));"
|
|||
|
|
"if($r){{"
|
|||
|
|
"$c=[System.Windows.Automation.PropertyCondition]::new("
|
|||
|
|
"[System.Windows.Automation.AutomationElement]"
|
|||
|
|
"::ControlTypeProperty,"
|
|||
|
|
"[System.Windows.Automation.ControlType]::Text);"
|
|||
|
|
"$ts=$r.FindAll("
|
|||
|
|
"[System.Windows.Automation.TreeScope]::Descendants,$c);"
|
|||
|
|
"foreach($t in $ts){{"
|
|||
|
|
"$n=$t.Current.Name;"
|
|||
|
|
"if($n -match '(\\d+)\\s*(songs?|titel|tracks?|titres?|morceaux"
|
|||
|
|
"|canciones|brani)'){{$matches[1];break}}"
|
|||
|
|
"}}}}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
r = subprocess.run(
|
|||
|
|
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
|||
|
|
"-Command", ps],
|
|||
|
|
capture_output=True, text=True, timeout=10,
|
|||
|
|
creationflags=0x08000000,
|
|||
|
|
)
|
|||
|
|
out = r.stdout.strip()
|
|||
|
|
if out:
|
|||
|
|
return int(out.split()[0])
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
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")
|
|||
|
|
n = len(self.songs)
|
|||
|
|
if self.playlist_total > 0:
|
|||
|
|
info = f"{self.track_number}/{self.playlist_total} Songs"
|
|||
|
|
else:
|
|||
|
|
info = f"{n} Songs aufgenommen"
|
|||
|
|
|
|||
|
|
not_complete = (self.playlist_total > 0
|
|||
|
|
and self.track_number < self.playlist_total)
|
|||
|
|
should_restart = not_complete and not self._user_stopped
|
|||
|
|
|
|||
|
|
if should_restart and self._restart_fails < 5:
|
|||
|
|
self._set_status(f"{info} \u2013 NEUSTART\u2026", ORANGE)
|
|||
|
|
self._restart_countdown(5)
|
|||
|
|
elif finished:
|
|||
|
|
self._set_status(f"FERTIG \u2013 {info}", GREEN)
|
|||
|
|
else:
|
|||
|
|
self._set_status(
|
|||
|
|
f"GESTOPPT \u2013 {info}" if n else "BEREIT",
|
|||
|
|
GREEN if n else DIM)
|
|||
|
|
self._user_stopped = False
|
|||
|
|
self.level_bar.set(0)
|
|||
|
|
|
|||
|
|
def _restart_countdown(self, secs):
|
|||
|
|
"""Count down and then auto-restart recording."""
|
|||
|
|
if secs <= 0:
|
|||
|
|
self._auto_restart()
|
|||
|
|
return
|
|||
|
|
if self._user_stopped or self.active:
|
|||
|
|
self._restart_after_id = None
|
|||
|
|
return
|
|||
|
|
info = f"{self.track_number}/{self.playlist_total}"
|
|||
|
|
self._set_status(
|
|||
|
|
f"{info} \u2013 NEUSTART IN {secs}s\u2026", ORANGE)
|
|||
|
|
self._restart_after_id = self.root.after(
|
|||
|
|
1000, lambda: self._restart_countdown(secs - 1))
|
|||
|
|
|
|||
|
|
def _auto_restart(self):
|
|||
|
|
"""Automatically restart recording to continue the playlist."""
|
|||
|
|
self._restart_after_id = None
|
|||
|
|
if self.active or self._user_stopped:
|
|||
|
|
return
|
|||
|
|
if (self.playlist_total > 0
|
|||
|
|
and self.track_number < self.playlist_total):
|
|||
|
|
prev_count = self.track_number
|
|||
|
|
self._start()
|
|||
|
|
self.root.after(
|
|||
|
|
15000, lambda p=prev_count: self._check_restart_progress(p))
|
|||
|
|
|
|||
|
|
def _check_restart_progress(self, prev_count):
|
|||
|
|
"""Increment fail counter if no new songs were recorded."""
|
|||
|
|
if self.track_number <= prev_count and self.active:
|
|||
|
|
self._restart_fails += 1
|
|||
|
|
else:
|
|||
|
|
self._restart_fails = 0
|
|||
|
|
|
|||
|
|
# ── track monitoring ───────────────────────────────────────
|
|||
|
|
|
|||
|
|
def _tick_monitor(self):
|
|||
|
|
if self.spotify_hwnd and user32.IsWindow(self.spotify_hwnd):
|
|||
|
|
try:
|
|||
|
|
title = get_window_title(self.spotify_hwnd)
|
|||
|
|
except Exception:
|
|||
|
|
title = ""
|
|||
|
|
|
|||
|
|
if self.active and self.current_track is None and self.is_playing:
|
|||
|
|
self.recorder.harvest_frames()
|
|||
|
|
|
|||
|
|
if not title:
|
|||
|
|
self.root.after(500, self._tick_monitor)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
if " - " 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:
|
|||
|
|
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(" - ", 1)
|
|||
|
|
self.artist_lbl.configure(text=parts[0].strip())
|
|||
|
|
self.title_lbl.configure(text=parts[1].strip())
|
|||
|
|
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()
|
|||
|
|
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:
|
|||
|
|
self.recorder.harvest_frames()
|
|||
|
|
self.pause_since = None
|
|||
|
|
|
|||
|
|
if (self.active and self.pause_since
|
|||
|
|
and self.auto_stop_secs > 0):
|
|||
|
|
all_done = (self.playlist_total > 0
|
|||
|
|
and self.track_number >= self.playlist_total)
|
|||
|
|
timeout = self.auto_stop_secs
|
|||
|
|
if self.playlist_total > 0 and not all_done:
|
|||
|
|
timeout = max(timeout, 300)
|
|||
|
|
elapsed = time.time() - self.pause_since
|
|||
|
|
remaining = timeout - elapsed
|
|||
|
|
if remaining <= 0:
|
|||
|
|
self._stop(finished=True)
|
|||
|
|
else:
|
|||
|
|
if all_done:
|
|||
|
|
lbl = f"Fertig! Stop {int(remaining)}s"
|
|||
|
|
elif self.playlist_total > 0:
|
|||
|
|
lbl = (f"{self.track_number}/{self.playlist_total}"
|
|||
|
|
f" \u23f8 {int(remaining)}s")
|
|||
|
|
else:
|
|||
|
|
lbl = f"Stop {int(remaining)}s"
|
|||
|
|
self.time_lbl.configure(
|
|||
|
|
text=lbl, text_color=ORANGE)
|
|||
|
|
|
|||
|
|
self.root.after(500, self._tick_monitor)
|
|||
|
|
|
|||
|
|
def _handle_track_change(self, new_title):
|
|||
|
|
parts = new_title.split(" - ", 1)
|
|||
|
|
new_artist, new_song = parts[0].strip(), parts[1].strip()
|
|||
|
|
|
|||
|
|
key = self._song_key(new_artist, new_song)
|
|||
|
|
if key in self.recorded_songs:
|
|||
|
|
with self._lock:
|
|||
|
|
self.recorder.harvest_frames()
|
|||
|
|
self.current_track = None
|
|||
|
|
self.artist_lbl.configure(text=new_artist)
|
|||
|
|
self.title_lbl.configure(text=f"\u2714 {new_song}")
|
|||
|
|
self.tnum_lbl.configure(text="BEREITS AUFGENOMMEN")
|
|||
|
|
self._set_status("\u23ed WARTE AUF NEUEN SONG", ORANGE)
|
|||
|
|
self.rec_start = time.time()
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
new_track = {"artist": new_artist, "title": new_song}
|
|||
|
|
with self._lock:
|
|||
|
|
frames = self.recorder.harvest_frames()
|
|||
|
|
if frames and self.current_track and self.track_number > 0:
|
|||
|
|
num = self.track_number
|
|||
|
|
trk = dict(self.current_track)
|
|||
|
|
threading.Thread(
|
|||
|
|
target=self._save, args=(frames, trk, num), daemon=True
|
|||
|
|
).start()
|
|||
|
|
self.track_number += 1
|
|||
|
|
self.current_track = new_track
|
|||
|
|
self.rec_start = time.time()
|
|||
|
|
|
|||
|
|
self._update_track_ui()
|
|||
|
|
self._set_status("AUFNAHME", GREEN)
|
|||
|
|
|
|||
|
|
# ── duplicate detection ─────────────────────────────────────
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _song_key(artist, title):
|
|||
|
|
return f"{artist}\n{title}".lower().strip()
|
|||
|
|
|
|||
|
|
def _scan_existing_songs(self):
|
|||
|
|
"""Scan session directory for already-recorded songs (by filename)."""
|
|||
|
|
found = set()
|
|||
|
|
if not self.session_dir or not os.path.isdir(self.session_dir):
|
|||
|
|
return found
|
|||
|
|
for f in os.listdir(self.session_dir):
|
|||
|
|
if not f.lower().endswith(".mp3"):
|
|||
|
|
continue
|
|||
|
|
name = f[:-4]
|
|||
|
|
parts = name.split(" - ", 2)
|
|||
|
|
if len(parts) >= 3:
|
|||
|
|
found.add(self._song_key(parts[1], parts[2]))
|
|||
|
|
return found
|
|||
|
|
|
|||
|
|
# ── 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:
|
|||
|
|
self.recorded_songs.add(self._song_key(artist, title))
|
|||
|
|
name = os.path.basename(path)
|
|||
|
|
self.songs.append(name)
|
|||
|
|
self.root.after(0, lambda: self._add_song(name))
|
|||
|
|
self.root.after(0, self._refresh_total_stats)
|
|||
|
|
|
|||
|
|
def _add_song(self, name):
|
|||
|
|
self.song_box.configure(state="normal")
|
|||
|
|
self.song_box.insert("end", f"\u2713 {name}\n")
|
|||
|
|
self.song_box.see("end")
|
|||
|
|
self.song_box.configure(state="disabled")
|
|||
|
|
if self.playlist_total > 0:
|
|||
|
|
self.cnt_lbl.configure(
|
|||
|
|
text=f"{self.track_number} / {self.playlist_total} Songs")
|
|||
|
|
else:
|
|||
|
|
self.cnt_lbl.configure(text=f"{len(self.songs)} Songs")
|
|||
|
|
|
|||
|
|
# ── UI helpers ─────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def _update_track_ui(self):
|
|||
|
|
if self.current_track:
|
|||
|
|
self.artist_lbl.configure(text=self.current_track["artist"])
|
|||
|
|
self.title_lbl.configure(text=self.current_track["title"])
|
|||
|
|
if self.playlist_total > 0:
|
|||
|
|
self.tnum_lbl.configure(
|
|||
|
|
text=f"TRACK #{self.track_number:02d}"
|
|||
|
|
f" / {self.playlist_total}")
|
|||
|
|
else:
|
|||
|
|
self.tnum_lbl.configure(
|
|||
|
|
text=f"TRACK #{self.track_number:02d}")
|
|||
|
|
|
|||
|
|
def _set_status(self, text, color):
|
|||
|
|
self.stat_lbl.configure(text=text, text_color=color)
|
|||
|
|
self.dot_lbl.configure(text_color=color)
|
|||
|
|
|
|||
|
|
def _tick_timer(self):
|
|||
|
|
if self.active and self.rec_start:
|
|||
|
|
s = int(time.time() - self.rec_start)
|
|||
|
|
m, s = divmod(s, 60)
|
|||
|
|
self.time_lbl.configure(text=f"{m:02d}:{s:02d}", text_color=GREEN)
|
|||
|
|
elif not self.active:
|
|||
|
|
self.time_lbl.configure(text="", text_color=DIM)
|
|||
|
|
self.root.after(500, self._tick_timer)
|
|||
|
|
|
|||
|
|
def _tick_level(self):
|
|||
|
|
if self.active:
|
|||
|
|
lvl = self.recorder.get_level()
|
|||
|
|
smooth = self.level_bar.get() * 0.35 + lvl * 0.65
|
|||
|
|
self.level_bar.set(smooth)
|
|||
|
|
self.level_bar.configure(
|
|||
|
|
progress_color=RED if lvl > 0.85 else GREEN)
|
|||
|
|
else:
|
|||
|
|
cur = self.level_bar.get()
|
|||
|
|
if cur > 0.01:
|
|||
|
|
self.level_bar.set(cur * 0.5)
|
|||
|
|
else:
|
|||
|
|
self.level_bar.set(0)
|
|||
|
|
self.root.after(80, self._tick_level)
|
|||
|
|
|
|||
|
|
def _tick_pulse(self):
|
|||
|
|
if self.active:
|
|||
|
|
self._pulse_on = not self._pulse_on
|
|||
|
|
self.dot_lbl.configure(
|
|||
|
|
text_color=GREEN if self._pulse_on else "#14532d")
|
|||
|
|
self.root.after(650, self._tick_pulse)
|
|||
|
|
|
|||
|
|
def _tick_alive(self):
|
|||
|
|
if self.spotify_hwnd:
|
|||
|
|
if user32.IsWindow(self.spotify_hwnd):
|
|||
|
|
self._alive_fails = 0
|
|||
|
|
else:
|
|||
|
|
self._alive_fails += 1
|
|||
|
|
new_hwnd = find_spotify_window()
|
|||
|
|
if new_hwnd:
|
|||
|
|
self.spotify_hwnd = new_hwnd
|
|||
|
|
self._alive_fails = 0
|
|||
|
|
else:
|
|||
|
|
not_done = (self.playlist_total > 0
|
|||
|
|
and self.track_number < self.playlist_total)
|
|||
|
|
threshold = 10 if not_done else 4
|
|||
|
|
if self._alive_fails >= threshold:
|
|||
|
|
self.spotify_hwnd = None
|
|||
|
|
self._update_sp_status(False)
|
|||
|
|
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)
|
|||
|
|
|
|||
|
|
# ── 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):
|
|||
|
|
p = filedialog.askdirectory(initialdir=self.output_base)
|
|||
|
|
if p:
|
|||
|
|
self.output_base = p
|
|||
|
|
self.folder_lbl.configure(text=p)
|
|||
|
|
self._refresh_total_stats()
|
|||
|
|
|
|||
|
|
def _open_folder(self):
|
|||
|
|
folder = self.session_dir if self.session_dir and os.path.isdir(
|
|||
|
|
self.session_dir) else self.output_base
|
|||
|
|
os.startfile(folder)
|
|||
|
|
|
|||
|
|
def _set_bitrate(self, val):
|
|||
|
|
self.bitrate = int(val.split()[0])
|
|||
|
|
|
|||
|
|
def _set_auto_stop(self, val):
|
|||
|
|
if val == "Aus":
|
|||
|
|
self.auto_stop_secs = 0
|
|||
|
|
else:
|
|||
|
|
self.auto_stop_secs = int(val.split()[0])
|
|||
|
|
|
|||
|
|
# ── cleanup ────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def _on_close(self):
|
|||
|
|
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._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)
|