From dc9605685e98c180c6bb36677b8067f759e73455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xeva=E1=B5=88=E1=B5=89=E1=B5=9B=20=5BCrystal=5D?= Date: Mon, 14 Sep 2026 12:46:27 +0000 Subject: [PATCH 1/6] Added build in spotify lyrics to status feature. --- .gitignore | 6 +- gui/components/settings/__init__.py | 3 +- gui/components/settings/spotify_lyrics.py | 124 +++++++++++++++ gui/main.py | 3 + gui/pages/settings.py | 10 +- utils/spotify_lyrics.py | 178 ++++++++++++++++++++++ 6 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 gui/components/settings/spotify_lyrics.py create mode 100644 utils/spotify_lyrics.py diff --git a/.gitignore b/.gitignore index 607edb4..c52e94b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,8 @@ tokens.txt build/ dist/ Ghost.spec -changelog.py \ No newline at end of file +changelog.py +lyrics +*.env +.env +env. \ No newline at end of file diff --git a/gui/components/settings/__init__.py b/gui/components/settings/__init__.py index 4438850..2f87e06 100644 --- a/gui/components/settings/__init__.py +++ b/gui/components/settings/__init__.py @@ -3,4 +3,5 @@ from .apis import APIsPanel from .session_spoofing import SessionSpoofingPanel from .rich_presence import RichPresencePanel -from .snipers import SnipersPanel \ No newline at end of file +from .snipers import SnipersPanel +from .spotify_lyrics import SpotifyLyricsPanel \ No newline at end of file diff --git a/gui/components/settings/spotify_lyrics.py b/gui/components/settings/spotify_lyrics.py new file mode 100644 index 0000000..2efdfb8 --- /dev/null +++ b/gui/components/settings/spotify_lyrics.py @@ -0,0 +1,124 @@ +import json +import os + +import ttkbootstrap as ttk + +from gui.components import RoundedButton, RoundedSwitch, SettingsPanel +from utils.files import get_application_support +from utils.spotify_lyrics import SpotifyLyricsService + + +class SpotifyLyricsPanel(SettingsPanel): + def __init__(self, root, parent, images, config, width=None): + super().__init__(root, parent, "Spotify Lyrics", images.get("rich_presence"), width=width, collapsed=False) + self.cfg = config + self.service = None + self.entries = {} + self.settings_path = os.path.join(get_application_support(), "lyrics", "settings.json") + self.lyrics_settings = self._load_settings() + + def _load_settings(self): + try: + with open(self.settings_path, "r") as settings_file: + return json.load(settings_file) + except (FileNotFoundError, json.JSONDecodeError): + return { + "credentials": {}, + "view": {"timestamp": True, "label": True}, + "timings": {"sendTimeOffset": 500, "enableAutooffset": True, "autooffset": 3}, + "update": {"enableAutoupdate": True}, + } + + def _credential(self, key, default=""): + return self.lyrics_settings.get("credentials", {}).get(key, default) + + def _save_settings(self): + credentials = self.lyrics_settings.setdefault("credentials", {}) + for key, entry in self.entries.items(): + credentials[key] = entry.get() + + view = self.lyrics_settings.setdefault("view", {}) + view["timestamp"] = self.timestamp_entry.instate(["selected"]) + view["label"] = self.label_entry.instate(["selected"]) + + os.makedirs(os.path.dirname(self.settings_path), exist_ok=True) + with open(self.settings_path, "w") as settings_file: + json.dump(self.lyrics_settings, settings_file, indent=4) + self.status_label.configure(text="Lyrics-Einstellungen gespeichert.") + + def _start(self): + self._save_settings() + if self.service and self.service.thread and self.service.thread.is_alive(): + self.status_label.configure(text="Spotify Lyrics läuft bereits.") + return + + self.service = SpotifyLyricsService( + self.lyrics_settings.get("credentials", {}), + show_timestamp=self.lyrics_settings.get("view", {}).get("timestamp", True), + show_label=self.lyrics_settings.get("view", {}).get("label", True), + status_callback=lambda message: self.root.after(0, lambda: self.status_label.configure(text=message)), + ) + self.service.start() + + def stop(self): + if not self.service: + return + self.service.stop() + self.service = None + + def _stop(self): + self.stop() + self.status_label.configure(text="Spotify Lyrics gestoppt.") + + def draw(self): + fields = [ + ("token", "Discord Token", self.cfg.get("token") or self._credential("token"), True), + ("cookies", "Spotify Cookies", self._credential("cookies"), True), + ("clientID", "Spotify Client ID", self._credential("clientID"), False), + ("clientSecret", "Spotify Client Secret", self._credential("clientSecret"), True), + ("refreshToken", "Spotify Refresh Token", self._credential("refreshToken"), True), + ] + + for row, (key, label_text, value, secret) in enumerate(fields): + label = ttk.Label(self.body, text=label_text) + label.configure(background=self.root.style.colors.get("dark")) + label.grid(row=row + 1, column=0, sticky=ttk.W, padx=(10, 10), pady=(2, 5)) + + entry = ttk.Entry(self.body, show="*" if secret else None) + entry.configure(foreground=self.root.style.colors.get("fg")) + entry.insert(0, value) + entry.grid(row=row + 1, column=1, sticky=ttk.EW, padx=(0, 10), pady=(2, 5)) + self.entries[key] = entry + + view = ttk.Frame(self.body, style="dark.TFrame") + view.grid(row=len(fields) + 1, column=0, columnspan=2, sticky=ttk.EW, padx=10, pady=(5, 5)) + view.grid_columnconfigure(0, weight=1) + + self.timestamp_entry = RoundedSwitch( + view, + variable=ttk.BooleanVar(value=self.lyrics_settings.get("view", {}).get("timestamp", True)), + parent_background=self.root.style.colors.get("dark"), + ) + self.timestamp_entry.grid(row=0, column=1, sticky=ttk.E, padx=10, pady=5) + ttk.Label(view, text="Zeitstempel anzeigen").grid(row=0, column=0, sticky=ttk.W, pady=5) + + self.label_entry = RoundedSwitch( + view, + variable=ttk.BooleanVar(value=self.lyrics_settings.get("view", {}).get("label", True)), + parent_background=self.root.style.colors.get("dark"), + ) + self.label_entry.grid(row=1, column=1, sticky=ttk.E, padx=10, pady=5) + ttk.Label(view, text="Lyrics-Label anzeigen").grid(row=1, column=0, sticky=ttk.W, pady=5) + + actions = ttk.Frame(self.body, style="dark.TFrame") + actions.grid(row=len(fields) + 2, column=0, columnspan=2, sticky=ttk.EW, padx=10, pady=(10, 5)) + RoundedButton(actions, text="Speichern", command=self._save_settings).pack(side=ttk.LEFT, padx=(0, 5)) + RoundedButton(actions, text="Starten", command=self._start).pack(side=ttk.LEFT, padx=5) + RoundedButton(actions, text="Stoppen", command=self._stop).pack(side=ttk.LEFT, padx=5) + + self.status_label = ttk.Label(self.body, text="Bereit") + self.status_label.configure(background=self.root.style.colors.get("dark")) + self.status_label.grid(row=len(fields) + 3, column=0, columnspan=2, sticky=ttk.W, padx=10, pady=(5, 10)) + + self.body.grid_columnconfigure(1, weight=1) + return self.wrapper \ No newline at end of file diff --git a/gui/main.py b/gui/main.py index b2a095d..4fd283b 100644 --- a/gui/main.py +++ b/gui/main.py @@ -376,6 +376,9 @@ def quit(self): # # os._exit(0) # self.root.destroy() # sys.exit(0) + spotify_lyrics_panel = getattr(self.settings_page, "spotify_lyrics_panel", None) + if spotify_lyrics_panel: + spotify_lyrics_panel.stop() self.root.destroy() sys.exit(0) diff --git a/gui/pages/settings.py b/gui/pages/settings.py index 056ef56..80a65de 100644 --- a/gui/pages/settings.py +++ b/gui/pages/settings.py @@ -1,6 +1,6 @@ import sys import ttkbootstrap as ttk -from gui.components.settings import GeneralPanel, ThemingPanel, APIsPanel, SessionSpoofingPanel, RichPresencePanel, SnipersPanel +from gui.components.settings import GeneralPanel, ThemingPanel, APIsPanel, SessionSpoofingPanel, RichPresencePanel, SnipersPanel, SpotifyLyricsPanel from gui.components import RoundedFrame, DropdownMenu from gui.helpers import Images, Style from utils.config import Config @@ -25,6 +25,7 @@ def __init__(self, root, bot_controller, draw_settings): # "session_spoofing": None, "rich_presence": None, "snipers": None, + "spotify_lyrics": None, } self.pills = {} @@ -33,6 +34,9 @@ def refresh_config(self): return try: + if getattr(self, "spotify_lyrics_panel", None): + self.spotify_lyrics_panel.stop() + for widget in self.parent.winfo_children(): widget.destroy() @@ -50,6 +54,8 @@ def _create_sections(self, wrapper): self.session_spoofing = SessionSpoofingPanel(self.root, general_wrapper, self.images, self.cfg).draw() self.snipers = SnipersPanel(self.root, wrapper, self.images, self.cfg).draw() self.rpc = RichPresencePanel(self.root, wrapper, self.images, self.cfg, bot_controller=self.bot_controller).draw() + self.spotify_lyrics_panel = SpotifyLyricsPanel(self.root, wrapper, self.images, self.cfg) + self.spotify_lyrics = self.spotify_lyrics_panel.draw() self.apis = APIsPanel(self.root, general_wrapper, self.images, self.cfg).draw() self.theming = ThemingPanel(self.root, wrapper, self.images, self.cfg, bot_controller=self.bot_controller).draw() @@ -63,6 +69,7 @@ def _create_sections(self, wrapper): # self.pages["session_spoofing"] = self.session_spoofing self.pages["rich_presence"] = self.rpc self.pages["snipers"] = self.snipers + self.pages["spotify_lyrics"] = self.spotify_lyrics def _create_pill(self, parent, text, row, command): def _hover_enter(_, pill, label): @@ -121,6 +128,7 @@ def draw(self, parent): # self._create_pill(self.pages_wrapper, "Session Spoofing", 3, lambda: self.toggle("session_spoofing")) self._create_pill(self.pages_wrapper, "Rich Presence", 4, lambda: self.toggle("rich_presence")) self._create_pill(self.pages_wrapper, "Snipers", 5, lambda: self.toggle("snipers")) + self._create_pill(self.pages_wrapper, "Spotify Lyrics", 6, lambda: self.toggle("spotify_lyrics")) # ------- diff --git a/utils/spotify_lyrics.py b/utils/spotify_lyrics.py new file mode 100644 index 0000000..cc4f7b8 --- /dev/null +++ b/utils/spotify_lyrics.py @@ -0,0 +1,178 @@ +import threading +import time + +import requests + + +class SpotifyLyricsService: + def __init__(self, credentials, show_timestamp=True, show_label=True, status_callback=None): + self.credentials = credentials + self.show_timestamp = show_timestamp + self.show_label = show_label + self.status_callback = status_callback + self.stop_event = threading.Event() + self.thread = None + self.spotify_token = None + self.spotify_token_expires_at = 0 + self.session = requests.Session() + + def _set_status(self, message): + if self.status_callback: + self.status_callback(message) + + def start(self): + if self.thread and self.thread.is_alive(): + return False + + self.stop_event.clear() + self.thread = threading.Thread(target=self._run, name="spotify-lyrics", daemon=True) + self.thread.start() + return True + + def stop(self): + self.stop_event.set() + if self.thread and self.thread.is_alive(): + self.thread.join(timeout=2) + self.thread = None + + def _refresh_spotify_token(self): + client_id = self.credentials.get("clientID", "").strip() + client_secret = self.credentials.get("clientSecret", "").strip() + refresh_token = self.credentials.get("refreshToken", "").strip() + if not client_id or not client_secret or not refresh_token: + raise ValueError("Spotify Client ID, Client Secret und Refresh Token werden benötigt") + + response = self.session.post( + "https://accounts.spotify.com/api/token", + auth=requests.auth.HTTPBasicAuth(client_id, client_secret), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={"grant_type": "refresh_token", "refresh_token": refresh_token}, + timeout=15, + ) + response.raise_for_status() + payload = response.json() + self.spotify_token = payload["access_token"] + self.spotify_token_expires_at = time.time() + payload.get("expires_in", 3600) - 60 + return True + + def _spotify_request(self, url): + if not self.spotify_token or time.time() >= self.spotify_token_expires_at: + self._refresh_spotify_token() + + response = self.session.get( + url, + headers={ + "Accept": "application/json", + "Accept-Language": "en", + "App-Platform": "WebPlayer", + "Authorization": f"Bearer {self.spotify_token}", + "Spotify-App-Version": "1.2.40.176.g6d58cb73", + "Cookie": self.credentials.get("cookies", ""), + "Referer": "https://open.spotify.com/", + }, + timeout=15, + ) + response.raise_for_status() + return response + + def _get_current_playback(self): + response = self._spotify_request("https://api.spotify.com/v1/me/player") + if response.status_code == 204: + return None + + payload = response.json() + item = payload.get("item") + if not payload.get("is_playing") or not item: + return None + + return { + "id": item.get("id"), + "name": item.get("name", ""), + "artist": ", ".join(artist.get("name", "") for artist in item.get("artists", [])), + "progress_ms": payload.get("progress_ms", 0), + } + + def _get_lyrics(self, track_id): + response = self._spotify_request( + f"https://spclient.wg.spotify.com/color-lyrics/v2/track/{track_id}" + "?format=json&vocalRemoval=false&market=from_token" + ) + lyrics = response.json().get("lyrics", {}) + if lyrics.get("showUpsell") or lyrics.get("syncType") == "UNSYNCED": + return [] + + return [ + {"time": int(line.get("startTimeMs", 0)), "text": line.get("words", "")} + for line in lyrics.get("lines", []) + ] + + def _current_line(self, lines, progress_ms): + current = None + for line in lines: + if line["time"] <= progress_ms: + current = line + else: + break + return current + + def _format_seconds(self, milliseconds): + seconds = max(0, round(milliseconds / 1000)) + return f"{seconds // 60}:{seconds % 60:02d}" + + def _format_status(self, line): + text = line["text"].replace("♪", "🎶") + prefix = f"[{self._format_seconds(line['time'])}] " if self.show_timestamp else "" + label = "Song lyrics - " if self.show_label else "" + return (prefix + label + text)[:128] + + def _change_discord_status(self, text): + token = self.credentials.get("token", "").strip() + if not token: + raise ValueError("Discord Token fehlt") + + response = self.session.patch( + "https://discord.com/api/v9/users/@me/settings", + headers={"Authorization": token, "Content-Type": "application/json"}, + json={ + "custom_status": { + "text": text, + "emoji_id": None, + "emoji_name": "🎶", + "expires_at": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime(time.time() + 60)), + } + }, + timeout=15, + ) + response.raise_for_status() + + def _run(self): + self._set_status("Spotify Lyrics wird gestartet ...") + last_track_id = None + last_line_time = None + lyrics = [] + + while not self.stop_event.is_set(): + try: + playback = self._get_current_playback() + if not playback: + self._set_status("Kein Spotify-Song läuft.") + self.stop_event.wait(5) + continue + + if playback["id"] != last_track_id: + lyrics = self._get_lyrics(playback["id"]) + last_track_id = playback["id"] + last_line_time = None + + line = self._current_line(lyrics, playback["progress_ms"]) + if line and line["text"] and line["time"] != last_line_time: + self._change_discord_status(self._format_status(line)) + last_line_time = line["time"] + self._set_status(f"{playback['artist']} - {playback['name']}") + + self.stop_event.wait(1) + except (requests.RequestException, KeyError, ValueError) as error: + self._set_status(f"Spotify Lyrics: {error}") + self.stop_event.wait(10) + + self._set_status("Spotify Lyrics gestoppt.") \ No newline at end of file From d00282630c437e0d06150f3dbfc30b8486c41d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xeva=E1=B5=88=E1=B5=89=E1=B5=9B=20=5BCrystal=5D?= Date: Mon, 14 Sep 2026 12:50:31 +0000 Subject: [PATCH 2/6] Added spotify lyrics to status changer in the settings tab. --- gui/components/settings/spotify_lyrics.py | 18 +++++++++--------- utils/spotify_lyrics.py | 10 +++++----- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/gui/components/settings/spotify_lyrics.py b/gui/components/settings/spotify_lyrics.py index 2efdfb8..6c23d57 100644 --- a/gui/components/settings/spotify_lyrics.py +++ b/gui/components/settings/spotify_lyrics.py @@ -44,12 +44,12 @@ def _save_settings(self): os.makedirs(os.path.dirname(self.settings_path), exist_ok=True) with open(self.settings_path, "w") as settings_file: json.dump(self.lyrics_settings, settings_file, indent=4) - self.status_label.configure(text="Lyrics-Einstellungen gespeichert.") + self.status_label.configure(text="Lyrics settings saved.") def _start(self): self._save_settings() if self.service and self.service.thread and self.service.thread.is_alive(): - self.status_label.configure(text="Spotify Lyrics läuft bereits.") + self.status_label.configure(text="Spotify Lyrics is already running.") return self.service = SpotifyLyricsService( @@ -68,7 +68,7 @@ def stop(self): def _stop(self): self.stop() - self.status_label.configure(text="Spotify Lyrics gestoppt.") + self.status_label.configure(text="Spotify Lyrics stopped.") def draw(self): fields = [ @@ -100,7 +100,7 @@ def draw(self): parent_background=self.root.style.colors.get("dark"), ) self.timestamp_entry.grid(row=0, column=1, sticky=ttk.E, padx=10, pady=5) - ttk.Label(view, text="Zeitstempel anzeigen").grid(row=0, column=0, sticky=ttk.W, pady=5) + ttk.Label(view, text="Show timestamps").grid(row=0, column=0, sticky=ttk.W, pady=5) self.label_entry = RoundedSwitch( view, @@ -108,15 +108,15 @@ def draw(self): parent_background=self.root.style.colors.get("dark"), ) self.label_entry.grid(row=1, column=1, sticky=ttk.E, padx=10, pady=5) - ttk.Label(view, text="Lyrics-Label anzeigen").grid(row=1, column=0, sticky=ttk.W, pady=5) + ttk.Label(view, text="Show lyrics label").grid(row=1, column=0, sticky=ttk.W, pady=5) actions = ttk.Frame(self.body, style="dark.TFrame") actions.grid(row=len(fields) + 2, column=0, columnspan=2, sticky=ttk.EW, padx=10, pady=(10, 5)) - RoundedButton(actions, text="Speichern", command=self._save_settings).pack(side=ttk.LEFT, padx=(0, 5)) - RoundedButton(actions, text="Starten", command=self._start).pack(side=ttk.LEFT, padx=5) - RoundedButton(actions, text="Stoppen", command=self._stop).pack(side=ttk.LEFT, padx=5) + RoundedButton(actions, text="Save", command=self._save_settings).pack(side=ttk.LEFT, padx=(0, 5)) + RoundedButton(actions, text="Start", command=self._start).pack(side=ttk.LEFT, padx=5) + RoundedButton(actions, text="Stop", command=self._stop).pack(side=ttk.LEFT, padx=5) - self.status_label = ttk.Label(self.body, text="Bereit") + self.status_label = ttk.Label(self.body, text="Ready") self.status_label.configure(background=self.root.style.colors.get("dark")) self.status_label.grid(row=len(fields) + 3, column=0, columnspan=2, sticky=ttk.W, padx=10, pady=(5, 10)) diff --git a/utils/spotify_lyrics.py b/utils/spotify_lyrics.py index cc4f7b8..2ece360 100644 --- a/utils/spotify_lyrics.py +++ b/utils/spotify_lyrics.py @@ -40,7 +40,7 @@ def _refresh_spotify_token(self): client_secret = self.credentials.get("clientSecret", "").strip() refresh_token = self.credentials.get("refreshToken", "").strip() if not client_id or not client_secret or not refresh_token: - raise ValueError("Spotify Client ID, Client Secret und Refresh Token werden benötigt") + raise ValueError("Spotify Client ID, client secret and refresh token are required") response = self.session.post( "https://accounts.spotify.com/api/token", @@ -128,7 +128,7 @@ def _format_status(self, line): def _change_discord_status(self, text): token = self.credentials.get("token", "").strip() if not token: - raise ValueError("Discord Token fehlt") + raise ValueError("Discord token is missing") response = self.session.patch( "https://discord.com/api/v9/users/@me/settings", @@ -146,7 +146,7 @@ def _change_discord_status(self, text): response.raise_for_status() def _run(self): - self._set_status("Spotify Lyrics wird gestartet ...") + self._set_status("Starting Spotify Lyrics ...") last_track_id = None last_line_time = None lyrics = [] @@ -155,7 +155,7 @@ def _run(self): try: playback = self._get_current_playback() if not playback: - self._set_status("Kein Spotify-Song läuft.") + self._set_status("No Spotify track is playing.") self.stop_event.wait(5) continue @@ -175,4 +175,4 @@ def _run(self): self._set_status(f"Spotify Lyrics: {error}") self.stop_event.wait(10) - self._set_status("Spotify Lyrics gestoppt.") \ No newline at end of file + self._set_status("Spotify Lyrics stopped.") \ No newline at end of file From 297467b853c225014f2b39af72dc6e73f8770dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xeva=E1=B5=88=E1=B5=89=E1=B5=9B=20=5BCrystal=5D?= Date: Mon, 14 Sep 2026 12:56:23 +0000 Subject: [PATCH 3/6] Added spotify lyrics to status changer in the settings tab. | Added spotify browser authentication with the developer API to make its usage more user friendly, Please note that this URL is needed for the authentication, Please add http://127.0.0.1:8999/callback to the auth on the spotify dev dashboard. --- gui/components/settings/spotify_lyrics.py | 20 ++++- utils/spotify_lyrics.py | 92 +++++++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/gui/components/settings/spotify_lyrics.py b/gui/components/settings/spotify_lyrics.py index 6c23d57..6d9f89d 100644 --- a/gui/components/settings/spotify_lyrics.py +++ b/gui/components/settings/spotify_lyrics.py @@ -14,6 +14,7 @@ def __init__(self, root, parent, images, config, width=None): self.cfg = config self.service = None self.entries = {} + self.auth_button = None self.settings_path = os.path.join(get_application_support(), "lyrics", "settings.json") self.lyrics_settings = self._load_settings() @@ -60,6 +61,21 @@ def _start(self): ) self.service.start() + def _authenticate(self): + self._save_settings() + credentials = self.lyrics_settings.setdefault("credentials", {}) + self.service = SpotifyLyricsService(credentials, status_callback=self._set_status) + self._set_status("Opening Spotify authentication ...") + self.service.authenticate(self._authentication_finished) + + def _set_status(self, message): + self.root.after(0, lambda: self.status_label.configure(text=message)) + + def _authentication_finished(self, success, message): + if success: + self.root.after(0, self._save_settings) + self._set_status(message) + def stop(self): if not self.service: return @@ -73,10 +89,8 @@ def _stop(self): def draw(self): fields = [ ("token", "Discord Token", self.cfg.get("token") or self._credential("token"), True), - ("cookies", "Spotify Cookies", self._credential("cookies"), True), ("clientID", "Spotify Client ID", self._credential("clientID"), False), ("clientSecret", "Spotify Client Secret", self._credential("clientSecret"), True), - ("refreshToken", "Spotify Refresh Token", self._credential("refreshToken"), True), ] for row, (key, label_text, value, secret) in enumerate(fields): @@ -113,6 +127,8 @@ def draw(self): actions = ttk.Frame(self.body, style="dark.TFrame") actions.grid(row=len(fields) + 2, column=0, columnspan=2, sticky=ttk.EW, padx=10, pady=(10, 5)) RoundedButton(actions, text="Save", command=self._save_settings).pack(side=ttk.LEFT, padx=(0, 5)) + self.auth_button = RoundedButton(actions, text="Authenticate with Spotify", command=self._authenticate) + self.auth_button.pack(side=ttk.LEFT, padx=5) RoundedButton(actions, text="Start", command=self._start).pack(side=ttk.LEFT, padx=5) RoundedButton(actions, text="Stop", command=self._stop).pack(side=ttk.LEFT, padx=5) diff --git a/utils/spotify_lyrics.py b/utils/spotify_lyrics.py index 2ece360..02b9700 100644 --- a/utils/spotify_lyrics.py +++ b/utils/spotify_lyrics.py @@ -1,10 +1,17 @@ +import base64 import threading import time +import urllib.parse +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer import requests class SpotifyLyricsService: + redirect_uri = "http://127.0.0.1:8999/callback" + auth_scopes = "user-read-currently-playing user-read-playback-state" + def __init__(self, credentials, show_timestamp=True, show_label=True, status_callback=None): self.credentials = credentials self.show_timestamp = show_timestamp @@ -29,6 +36,91 @@ def start(self): self.thread.start() return True + def authenticate(self, callback=None): + client_id = self.credentials.get("clientID", "").strip() + if not client_id: + raise ValueError("Spotify Client ID is required") + + auth_url = "https://accounts.spotify.com/authorize?" + urllib.parse.urlencode({ + "client_id": client_id, + "response_type": "code", + "redirect_uri": self.redirect_uri, + "scope": self.auth_scopes, + }) + + service = self + callback_result = callback or (lambda success, message: None) + + class CallbackHandler(BaseHTTPRequestHandler): + def do_GET(self): + query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + if query.get("error"): + message = query["error"][0] + self._respond("Spotify authentication was cancelled.") + callback_result(False, message) + return + + code = query.get("code", [None])[0] + if not code: + self._respond("Spotify authentication failed. You can close this window.") + callback_result(False, "Authorization code is missing") + return + + try: + service._exchange_code(code) + self._respond("Spotify authentication successful. You can close this window.") + callback_result(True, "Spotify authentication successful.") + except (requests.RequestException, KeyError, ValueError) as error: + self._respond("Spotify authentication failed. You can close this window.") + callback_result(False, str(error)) + + def log_message(self, format, *args): + return + + def _respond(self, message): + body = f"

{message}

".encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def wait_for_callback(): + try: + server = HTTPServer(("127.0.0.1", 8999), CallbackHandler) + server.handle_request() + server.server_close() + except OSError as error: + callback_result(False, f"Could not start Spotify callback server: {error}") + + threading.Thread(target=wait_for_callback, name="spotify-oauth", daemon=True).start() + webbrowser.open(auth_url) + + def _exchange_code(self, code): + client_id = self.credentials.get("clientID", "").strip() + client_secret = self.credentials.get("clientSecret", "").strip() + if not client_id or not client_secret: + raise ValueError("Spotify Client ID and client secret are required") + + response = self.session.post( + "https://accounts.spotify.com/api/token", + headers={ + "Authorization": "Basic " + base64.b64encode(f"{client_id}:{client_secret}".encode()).decode(), + "Content-Type": "application/x-www-form-urlencoded", + }, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": self.redirect_uri, + }, + timeout=15, + ) + response.raise_for_status() + payload = response.json() + self.credentials["refreshToken"] = payload["refresh_token"] + self.spotify_token = payload["access_token"] + self.spotify_token_expires_at = time.time() + payload.get("expires_in", 3600) - 60 + def stop(self): self.stop_event.set() if self.thread and self.thread.is_alive(): From a7415ed27b47eda854386981b17776746cca6c90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xeva=E1=B5=88=E1=B5=89=E1=B5=9B=20=5BCrystal=5D?= Date: Mon, 14 Sep 2026 13:02:02 +0000 Subject: [PATCH 4/6] Added spotify lyrics to status changer in the settings tab. | Added spotify browser authentication with the developer API to make its usage more user friendly, Please note that this URL is needed for the authentication, Please add http://127.0.0.1:8999/callback to the auth on the spotify dev dashboard. --- gui/components/settings/spotify_lyrics.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/gui/components/settings/spotify_lyrics.py b/gui/components/settings/spotify_lyrics.py index 6d9f89d..d42c9b0 100644 --- a/gui/components/settings/spotify_lyrics.py +++ b/gui/components/settings/spotify_lyrics.py @@ -33,7 +33,7 @@ def _load_settings(self): def _credential(self, key, default=""): return self.lyrics_settings.get("credentials", {}).get(key, default) - def _save_settings(self): + def _save_settings(self, _event=None): credentials = self.lyrics_settings.setdefault("credentials", {}) for key, entry in self.entries.items(): credentials[key] = entry.get() @@ -57,11 +57,11 @@ def _start(self): self.lyrics_settings.get("credentials", {}), show_timestamp=self.lyrics_settings.get("view", {}).get("timestamp", True), show_label=self.lyrics_settings.get("view", {}).get("label", True), - status_callback=lambda message: self.root.after(0, lambda: self.status_label.configure(text=message)), + status_callback=self._set_status, ) self.service.start() - def _authenticate(self): + def _authenticate(self, _event=None): self._save_settings() credentials = self.lyrics_settings.setdefault("credentials", {}) self.service = SpotifyLyricsService(credentials, status_callback=self._set_status) @@ -69,7 +69,12 @@ def _authenticate(self): self.service.authenticate(self._authentication_finished) def _set_status(self, message): - self.root.after(0, lambda: self.status_label.configure(text=message)) + try: + if not self.status_label.winfo_exists(): + return + self.root.after(0, lambda: self.status_label.configure(text=message) if self.status_label.winfo_exists() else None) + except Exception: + return def _authentication_finished(self, success, message): if success: From 84c2edd2ad09caaae55a2e8b337568c3a9f278cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xeva=E1=B5=88=E1=B5=89=E1=B5=9B=20=5BCrystal=5D?= Date: Mon, 14 Sep 2026 13:04:18 +0000 Subject: [PATCH 5/6] I am losing my hopes on this working. --- gui/components/settings/spotify_lyrics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/components/settings/spotify_lyrics.py b/gui/components/settings/spotify_lyrics.py index d42c9b0..95be40e 100644 --- a/gui/components/settings/spotify_lyrics.py +++ b/gui/components/settings/spotify_lyrics.py @@ -47,7 +47,7 @@ def _save_settings(self, _event=None): json.dump(self.lyrics_settings, settings_file, indent=4) self.status_label.configure(text="Lyrics settings saved.") - def _start(self): + def _start(self, _event=None): self._save_settings() if self.service and self.service.thread and self.service.thread.is_alive(): self.status_label.configure(text="Spotify Lyrics is already running.") @@ -87,7 +87,7 @@ def stop(self): self.service.stop() self.service = None - def _stop(self): + def _stop(self, _event=None): self.stop() self.status_label.configure(text="Spotify Lyrics stopped.") From f3bbc0738509d1026225b58fa3e39bc084224baa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xeva=E1=B5=88=E1=B5=89=E1=B5=9B=20=5BCrystal=5D?= Date: Mon, 14 Sep 2026 13:06:49 +0000 Subject: [PATCH 6/6] I am losing my hopes on this working. | here we go again and test --- utils/spotify_lyrics.py | 56 ++++++++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/utils/spotify_lyrics.py b/utils/spotify_lyrics.py index 02b9700..fb836ca 100644 --- a/utils/spotify_lyrics.py +++ b/utils/spotify_lyrics.py @@ -177,26 +177,54 @@ def _get_current_playback(self): if not payload.get("is_playing") or not item: return None + track_id = item.get("id") + if not track_id: + return None + return { - "id": item.get("id"), + "id": track_id, "name": item.get("name", ""), "artist": ", ".join(artist.get("name", "") for artist in item.get("artists", [])), "progress_ms": payload.get("progress_ms", 0), } - def _get_lyrics(self, track_id): - response = self._spotify_request( - f"https://spclient.wg.spotify.com/color-lyrics/v2/track/{track_id}" - "?format=json&vocalRemoval=false&market=from_token" - ) - lyrics = response.json().get("lyrics", {}) - if lyrics.get("showUpsell") or lyrics.get("syncType") == "UNSYNCED": - return [] + def _get_lyrics(self, playback): + try: + response = self._spotify_request( + f"https://spclient.wg.spotify.com/color-lyrics/v2/track/{playback['id']}" + "?format=json&vocalRemoval=false&market=from_token" + ) + lyrics = response.json().get("lyrics", {}) + if not lyrics.get("showUpsell") and lyrics.get("syncType") != "UNSYNCED": + return [ + {"time": int(line.get("startTimeMs", 0)), "text": line.get("words", "")} + for line in lyrics.get("lines", []) + ] + except requests.RequestException: + pass - return [ - {"time": int(line.get("startTimeMs", 0)), "text": line.get("words", "")} - for line in lyrics.get("lines", []) - ] + response = self.session.get( + "https://lrclib.net/api/get", + params={"track_name": playback["name"], "artist_name": playback["artist"]}, + timeout=15, + ) + response.raise_for_status() + synced_lyrics = response.json().get("syncedLyrics") or "" + return self._parse_lrc(synced_lyrics) + + def _parse_lrc(self, synced_lyrics): + lines = [] + for raw_line in synced_lyrics.splitlines(): + if not raw_line.startswith("[") or "]" not in raw_line: + continue + timestamp, text = raw_line.split("]", 1) + try: + minutes, seconds = timestamp[1:].split(":", 1) + time_ms = round((int(minutes) * 60 + float(seconds)) * 1000) + except ValueError: + continue + lines.append({"time": time_ms, "text": text.strip()}) + return lines def _current_line(self, lines, progress_ms): current = None @@ -252,7 +280,7 @@ def _run(self): continue if playback["id"] != last_track_id: - lyrics = self._get_lyrics(playback["id"]) + lyrics = self._get_lyrics(playback) last_track_id = playback["id"] last_line_time = None