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..95be40e --- /dev/null +++ b/gui/components/settings/spotify_lyrics.py @@ -0,0 +1,145 @@ +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.auth_button = None + 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, _event=None): + 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 settings saved.") + + 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.") + 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=self._set_status, + ) + self.service.start() + + def _authenticate(self, _event=None): + 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): + 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: + self.root.after(0, self._save_settings) + self._set_status(message) + + def stop(self): + if not self.service: + return + self.service.stop() + self.service = None + + def _stop(self, _event=None): + self.stop() + self.status_label.configure(text="Spotify Lyrics stopped.") + + def draw(self): + fields = [ + ("token", "Discord Token", self.cfg.get("token") or self._credential("token"), True), + ("clientID", "Spotify Client ID", self._credential("clientID"), False), + ("clientSecret", "Spotify Client Secret", self._credential("clientSecret"), 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="Show timestamps").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="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="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) + + 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)) + + 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..fb836ca --- /dev/null +++ b/utils/spotify_lyrics.py @@ -0,0 +1,298 @@ +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 + 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 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"