2732 lines
106 KiB
Python
2732 lines
106 KiB
Python
"""
|
||
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)
|