This commit is contained in:
2026-04-16 13:32:32 +02:00
parent 7cc162a645
commit 3bdc930d6e
21608 changed files with 3990969 additions and 1168 deletions

View File

@@ -294,7 +294,8 @@ class SpotifyRecorderApp:
self._load_hicons()
self.root.after(200, self._apply_initial_icon)
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
self._tick_icon()
self.root.after(5000, lambda: self._safe_timer(
self._tick_icon, 5000))
self._app_initialized = False
self._show_selector()
@@ -429,6 +430,8 @@ class SpotifyRecorderApp:
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
@@ -437,11 +440,12 @@ class SpotifyRecorderApp:
threading.Thread(target=self._attach_app, daemon=True).start()
self._tick_monitor()
self._tick_timer()
self._tick_level()
self._tick_pulse()
self._tick_alive()
self._tick_suggest()
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 ───────────────────────────────────────
@@ -791,6 +795,7 @@ class SpotifyRecorderApp:
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)
@@ -815,6 +820,17 @@ class SpotifyRecorderApp:
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,
@@ -867,11 +883,10 @@ class SpotifyRecorderApp:
and not self._detecting):
self._detecting = True
threading.Thread(target=self._suggest_async, daemon=True).start()
self.root.after(4000, self._tick_suggest)
def _suggest_async(self):
try:
name, _ = self._detect_playlist_info()
name, _ = self._detect_playlist_info(allow_unembed=False)
if name:
self.root.after(0, lambda n=name: self._show_suggestion(n))
except Exception:
@@ -1088,7 +1103,8 @@ class SpotifyRecorderApp:
detected_name, total = None, 0
if has_app and self.spotify_hwnd:
detected_name, total = self._detect_playlist_info()
detected_name, total = self._detect_playlist_info(
allow_unembed=True)
if self._folder_mode == "custom":
name = (user_name
@@ -1240,7 +1256,6 @@ class SpotifyRecorderApp:
self.recorded_songs = self._scan_existing_songs()
existing = len(self.recorded_songs)
max_num = self._get_max_track_num()
self._cache = self._load_cache()
self._cached_song_keys = set()
@@ -1275,7 +1290,7 @@ class SpotifyRecorderApp:
self.active = True
if total > 0:
self.playlist_total = total
self.track_number = max(existing, max_num)
self.track_number = existing
self.current_track = None
self.songs = []
self.last_title = ""
@@ -1303,7 +1318,8 @@ class SpotifyRecorderApp:
self.is_playing = not was_paused
self._cache_song(artist, song)
if self._song_key(artist, song) in self.recorded_songs:
key = self._song_key(artist, song)
if key in self.recorded_songs:
skipping = True
self.artist_lbl.configure(text=artist)
self.title_lbl.configure(
@@ -1313,6 +1329,7 @@ class SpotifyRecorderApp:
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()
@@ -1389,32 +1406,43 @@ class SpotifyRecorderApp:
ctypes.byref(pid))
return pid.value
def _detect_playlist_info(self):
"""Detect playlist name AND track count via UI Automation + cache."""
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
pid = self._get_spotify_pid()
if not pid:
if not allow_unembed:
return cached_name or None, cached_total
ps = (
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;"
"$root=[System.Windows.Automation.AutomationElement]"
"::RootElement;"
"$pc=[System.Windows.Automation.PropertyCondition]::new("
"[System.Windows.Automation.AutomationElement]"
"::ProcessIdProperty,"
f"{pid});"
"$els=$root.FindAll("
"[System.Windows.Automation.TreeScope]::Descendants,$pc);"
"$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 $els){"
"foreach($l in $ls){"
"if($cnt -ge 400){break};"
"$n=$l.Current.Name;"
"$b=$l.Current.BoundingRectangle;"
@@ -1424,12 +1452,59 @@ class SpotifyRecorderApp:
"}else{"
"'0|0|'+$n"
"};"
"$cnt++}}"
"$cnt++}}}"
)
ps = ps_tpl.replace("_HWND_", str(hwnd_val))
stdout1, stderr1 = run_powershell(ps, timeout=8)
raw = [ln.strip() for ln in stdout1.split("\n")
if ln.strip() and "|" in ln]
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(
@@ -1437,9 +1512,10 @@ class SpotifyRecorderApp:
"ui_automation_debug.log")
with open(log, "w", encoding="utf-8") as f:
f.write(f"[{datetime.datetime.now()}] "
f"pid={pid} elements={len(raw)}\n")
if stderr1:
f.write(f"stderr: {stderr1}\n")
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:
@@ -1846,14 +1922,14 @@ class SpotifyRecorderApp:
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)
total_rec = len(self.recorded_songs)
if self.playlist_total > 0:
info = f"{self.track_number}/{self.playlist_total} Songs"
info = f"{total_rec}/{self.playlist_total} Songs"
else:
info = f"{n} Songs aufgenommen"
info = f"{total_rec} Songs aufgenommen"
not_complete = (self.playlist_total > 0
and self.track_number < self.playlist_total)
and total_rec < self.playlist_total)
should_restart = not_complete and not self._user_stopped
if should_restart and self._restart_fails < 5:
@@ -1863,8 +1939,8 @@ class SpotifyRecorderApp:
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)
f"GESTOPPT \u2013 {info}" if total_rec else "BEREIT",
GREEN if total_rec else DIM)
self._user_stopped = False
self.level_bar.set(0)
@@ -1876,7 +1952,8 @@ class SpotifyRecorderApp:
if self._user_stopped or self.active:
self._restart_after_id = None
return
info = f"{self.track_number}/{self.playlist_total}"
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(
@@ -1887,16 +1964,18 @@ class SpotifyRecorderApp:
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 self.track_number < self.playlist_total):
prev_count = self.track_number
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."""
if self.track_number <= prev_count and self.active:
total_rec = len(self.recorded_songs)
if total_rec <= prev_count and self.active:
self._restart_fails += 1
else:
self._restart_fails = 0
@@ -1991,6 +2070,7 @@ class SpotifyRecorderApp:
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}
@@ -2057,10 +2137,11 @@ class SpotifyRecorderApp:
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 self.track_number >= self.playlist_total)
and total_rec >= self.playlist_total)
timeout = self.auto_stop_secs
if not all_done and self.track_number > 0:
if not all_done and total_rec > 0:
timeout = max(timeout, 300)
elapsed = time.time() - self.pause_since
remaining = timeout - elapsed
@@ -2070,7 +2151,7 @@ class SpotifyRecorderApp:
if all_done:
lbl = f"Fertig! Stop {int(remaining)}s"
elif self.playlist_total > 0:
lbl = (f"{self.track_number}/{self.playlist_total}"
lbl = (f"{total_rec}/{self.playlist_total}"
f" \u23f8 {int(remaining)}s")
else:
lbl = f"Stop {int(remaining)}s"
@@ -2132,6 +2213,7 @@ class SpotifyRecorderApp:
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()
@@ -2152,7 +2234,14 @@ class SpotifyRecorderApp:
@staticmethod
def _song_key(artist, title):
return f"{artist}\n{title}".lower().strip()
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.
@@ -2271,6 +2360,10 @@ class SpotifyRecorderApp:
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:
@@ -2285,11 +2378,9 @@ class SpotifyRecorderApp:
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
if os.path.exists(path):
self.recorded_songs.add(key)
return
ok = self.recorder.save_mp3(
frames, path, title=title, artist=artist,
@@ -2297,7 +2388,7 @@ class SpotifyRecorderApp:
track_num=number,
)
if ok:
self.recorded_songs.add(self._song_key(artist, title))
self.recorded_songs.add(key)
self._cache_song(artist, title)
self._save_cache()
name = os.path.basename(path)
@@ -2324,11 +2415,12 @@ class SpotifyRecorderApp:
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"{self.track_number} / {self.playlist_total} Songs")
text=f"{total_rec} / {self.playlist_total} Songs")
else:
self.cnt_lbl.configure(text=f"{len(self.songs)} Songs")
self.cnt_lbl.configure(text=f"{total_rec} Songs")
# ── sleep prevention ───────────────────────────────────────
@@ -2366,7 +2458,24 @@ class SpotifyRecorderApp:
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 _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:
@@ -2381,14 +2490,12 @@ class SpotifyRecorderApp:
self.level_bar.set(cur * 0.5)
else:
self.level_bar.set(0)
self.root.after(80, self._tick_level)
def _tick_pulse(self):
if self.active:
self._pulse_on = not self._pulse_on
self.dot_lbl.configure(
text_color=GREEN if self._pulse_on else "#3E4D1E")
self.root.after(650, self._tick_pulse)
def _tick_alive(self):
proc = self.platform_cfg.get("process")
@@ -2402,8 +2509,9 @@ class SpotifyRecorderApp:
self.spotify_hwnd = new_hwnd
self._alive_fails = 0
else:
total_rec = len(self.recorded_songs)
not_done = (self.playlist_total > 0
and self.track_number < self.playlist_total)
and total_rec < self.playlist_total)
threshold = 30 if not_done else 15
if self._alive_fails >= threshold:
self.spotify_hwnd = None
@@ -2423,9 +2531,13 @@ class SpotifyRecorderApp:
if not self.recorder._stream.is_active():
self.recorder.stop()
recovered = False
for attempt in range(3):
for attempt in range(6):
try:
time.sleep(0.5 * (attempt + 1))
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
@@ -2444,15 +2556,15 @@ class SpotifyRecorderApp:
f.write(
f"\n[{datetime.datetime.now()}] "
"WASAPI stream lost, "
"recovery failed after 3 attempts\n")
"recovery failed after 6 attempts\n")
except Exception:
pass
self._stop()
else:
self._set_status("AUFNAHME", GREEN)
except Exception:
pass
self.root.after(3000, self._tick_alive)
# ── statistics ─────────────────────────────────────────────
def _count_total_mp3s(self):
@@ -2568,15 +2680,11 @@ class SpotifyRecorderApp:
return None
def _tick_icon(self):
try:
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)
except Exception:
pass
self.root.after(5000, self._tick_icon)
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: