From 3eda3245cae1c837680573252f8ae4277a80f452 Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Sun, 12 Oct 2025 00:23:26 +0700 Subject: [PATCH] v4.9 --- SpotiFLAC.py | 280 +++++++++++++++++++++++---------- getMetadata.py | 22 ++- getSecret.py | 111 +++++++++++++ icons/circle-x.svg | 5 + deezer.png => icons/deezer.png | Bin icons/download.svg | 5 + icon.ico => icons/icon.ico | Bin icon.svg => icons/icon.svg | 0 tidal.png => icons/tidal.png | Bin icons/tool.svg | 3 + icons/trash.svg | 6 + requirements.txt | 3 +- 12 files changed, 346 insertions(+), 89 deletions(-) create mode 100644 getSecret.py create mode 100644 icons/circle-x.svg rename deezer.png => icons/deezer.png (100%) create mode 100644 icons/download.svg rename icon.ico => icons/icon.ico (100%) rename icon.svg => icons/icon.svg (100%) rename tidal.png => icons/tidal.png (100%) create mode 100644 icons/tool.svg create mode 100644 icons/trash.svg diff --git a/SpotiFLAC.py b/SpotiFLAC.py index 556fb6d..08a7725 100644 --- a/SpotiFLAC.py +++ b/SpotiFLAC.py @@ -13,15 +13,17 @@ from PyQt6.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit, QLabel, QFileDialog, QListWidget, QTextEdit, QTabWidget, QButtonGroup, QRadioButton, QAbstractItemView, QProgressBar, QCheckBox, QDialog, - QDialogButtonBox, QComboBox, QStyledItemDelegate + QDialogButtonBox, QComboBox, QStyledItemDelegate, QMessageBox ) from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl, QTimer, QTime, QSettings, QSize -from PyQt6.QtGui import QIcon, QTextCursor, QDesktopServices, QPixmap, QBrush +from PyQt6.QtGui import QIcon, QTextCursor, QDesktopServices, QPixmap, QBrush, QPainter, QColor +from PyQt6.QtSvg import QSvgRenderer from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply from getMetadata import get_filtered_data, parse_uri, SpotifyInvalidUrlException from tidalDL import TidalDownloader from deezerDL import DeezerDownloader +from getSecret import scrape_and_save @dataclass class Track: @@ -35,6 +37,25 @@ class Track: isrc: str = "" release_date: str = "" +class SecretScrapeWorker(QThread): + finished = pyqtSignal(bool, str) + progress = pyqtSignal(str) + + def run(self): + try: + self.progress.emit("Fixing error...") + self.progress.emit("Please wait, this may take a moment...") + + success, message = scrape_and_save(progress_callback=self.progress.emit) + + if success: + self.finished.emit(True, "Fixed successfully!") + else: + self.finished.emit(False, message) + + except Exception as e: + self.finished.emit(False, f"Error: {str(e)}") + class MetadataFetchWorker(QThread): finished = pyqtSignal(dict) error = pyqtSignal(str) @@ -392,8 +413,8 @@ class ServiceComboBox(QComboBox): current_dir = os.path.dirname(os.path.abspath(__file__)) self.services = [ - {'id': 'tidal', 'name': 'Tidal', 'icon': 'tidal.png', 'online': False}, - {'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png', 'online': False} + {'id': 'tidal', 'name': 'Tidal', 'icon': 'icons/tidal.png', 'online': False}, + {'id': 'deezer', 'name': 'Deezer', 'icon': 'icons/deezer.png', 'online': False} ] for service in self.services: @@ -455,7 +476,7 @@ class ServiceComboBox(QComboBox): class SpotiFLACGUI(QWidget): def __init__(self): super().__init__() - self.current_version = "4.8" + self.current_version = "4.9" self.tracks = [] self.all_tracks = [] self.successful_downloads = [] @@ -530,6 +551,7 @@ class SpotiFLACGUI(QWidget): def reset_ui(self): self.track_list.clear() + self.track_list.show() self.log_output.clear() self.progress_bar.setValue(0) self.progress_bar.hide() @@ -542,13 +564,33 @@ class SpotiFLACGUI(QWidget): self.search_input.clear() if hasattr(self, 'search_widget'): self.search_widget.hide() + + def get_themed_icon(self, icon_name): + icon_path = os.path.join(os.path.dirname(__file__), "icons", icon_name) + if not os.path.exists(icon_path): + return QIcon() + + with open(icon_path, 'r') as f: + svg_content = f.read() + + svg_content = svg_content.replace('currentColor', self.current_theme_color) + + renderer = QSvgRenderer(svg_content.encode()) + pixmap = QPixmap(16, 16) + pixmap.fill(QColor(0, 0, 0, 0)) + + painter = QPainter(pixmap) + renderer.render(painter) + painter.end() + + return QIcon(pixmap) def initUI(self): self.setWindowTitle('SpotiFLAC') self.setFixedWidth(650) self.setMinimumHeight(350) - icon_path = os.path.join(os.path.dirname(__file__), "icon.svg") + icon_path = os.path.join(os.path.dirname(__file__), "icons", "icon.svg") if os.path.exists(icon_path): self.setWindowIcon(QIcon(icon_path)) @@ -766,42 +808,42 @@ class SpotiFLACGUI(QWidget): def setup_track_buttons(self): self.btn_layout = QHBoxLayout() - self.download_selected_btn = QPushButton('Download Selected') - self.download_all_btn = QPushButton('Download All') - self.remove_btn = QPushButton('Remove Selected') - self.clear_btn = QPushButton('Clear') + self.download_btn = QPushButton(' Download') + self.download_btn.setIcon(self.get_themed_icon('download.svg')) + self.delete_btn = QPushButton(' Delete') + self.delete_btn.setIcon(self.get_themed_icon('trash.svg')) - for btn in [self.download_selected_btn, self.download_all_btn, self.remove_btn, self.clear_btn]: - btn.setMinimumWidth(120) + for btn in [self.download_btn, self.delete_btn]: + btn.setFixedWidth(120) btn.setCursor(Qt.CursorShape.PointingHandCursor) - self.download_selected_btn.clicked.connect(self.download_selected) - self.download_all_btn.clicked.connect(self.download_all) - self.remove_btn.clicked.connect(self.remove_selected_tracks) - self.clear_btn.clicked.connect(self.clear_tracks) + self.download_btn.clicked.connect(self.download_tracks_action) + self.delete_btn.clicked.connect(self.delete_tracks) self.btn_layout.addStretch() - for btn in [self.download_selected_btn, self.download_all_btn, self.remove_btn, self.clear_btn]: - self.btn_layout.addWidget(btn, 1) + self.btn_layout.addWidget(self.download_btn) + self.btn_layout.addWidget(self.delete_btn) self.btn_layout.addStretch() self.single_track_container = QWidget() single_track_layout = QHBoxLayout(self.single_track_container) single_track_layout.setContentsMargins(0, 0, 0, 0) - self.single_download_btn = QPushButton('Download') - self.single_clear_btn = QPushButton('Clear') + self.single_download_btn = QPushButton(' Download') + self.single_download_btn.setIcon(self.get_themed_icon('download.svg')) + self.single_delete_btn = QPushButton(' Delete') + self.single_delete_btn.setIcon(self.get_themed_icon('trash.svg')) - for btn in [self.single_download_btn, self.single_clear_btn]: + for btn in [self.single_download_btn, self.single_delete_btn]: btn.setFixedWidth(120) btn.setCursor(Qt.CursorShape.PointingHandCursor) - self.single_download_btn.clicked.connect(self.download_all) - self.single_clear_btn.clicked.connect(self.clear_tracks) + self.single_download_btn.clicked.connect(self.download_tracks_action) + self.single_delete_btn.clicked.connect(self.delete_tracks) single_track_layout.addStretch() single_track_layout.addWidget(self.single_download_btn) - single_track_layout.addWidget(self.single_clear_btn) + single_track_layout.addWidget(self.single_delete_btn) single_track_layout.addStretch() self.single_track_container.hide() @@ -815,6 +857,18 @@ class SpotiFLACGUI(QWidget): self.log_output.setReadOnly(True) process_layout.addWidget(self.log_output) + fix_error_layout = QHBoxLayout() + fix_error_layout.addStretch() + self.fix_error_btn = QPushButton(' Fix Error') + self.fix_error_btn.setIcon(self.get_themed_icon('tool.svg')) + self.fix_error_btn.setFixedWidth(120) + self.fix_error_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.fix_error_btn.clicked.connect(self.fix_error_action) + self.fix_error_btn.hide() + fix_error_layout.addWidget(self.fix_error_btn) + fix_error_layout.addStretch() + process_layout.addLayout(fix_error_layout) + progress_time_layout = QVBoxLayout() progress_time_layout.setSpacing(2) @@ -840,7 +894,8 @@ class SpotiFLACGUI(QWidget): self.stop_btn.clicked.connect(self.stop_download) self.pause_resume_btn.clicked.connect(self.toggle_pause_resume) - self.remove_successful_btn = QPushButton('Remove Finished Songs') + self.remove_successful_btn = QPushButton(' Remove Finished Tracks') + self.remove_successful_btn.setIcon(self.get_themed_icon('circle-x.svg')) self.remove_successful_btn.setFixedWidth(200) self.remove_successful_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.remove_successful_btn.clicked.connect(self.remove_successful_downloads) @@ -1214,6 +1269,25 @@ class SpotiFLACGUI(QWidget): } ) + self.refresh_button_icons() + + def refresh_button_icons(self): + if hasattr(self, 'download_btn'): + self.download_btn.setIcon(self.get_themed_icon('download.svg')) + if hasattr(self, 'delete_btn'): + self.delete_btn.setIcon(self.get_themed_icon('trash.svg')) + + if hasattr(self, 'single_download_btn'): + self.single_download_btn.setIcon(self.get_themed_icon('download.svg')) + if hasattr(self, 'single_delete_btn'): + self.single_delete_btn.setIcon(self.get_themed_icon('trash.svg')) + + if hasattr(self, 'fix_error_btn'): + self.fix_error_btn.setIcon(self.get_themed_icon('tool.svg')) + + if hasattr(self, 'remove_successful_btn'): + self.remove_successful_btn.setIcon(self.get_themed_icon('circle-x.svg')) + def setup_about_tab(self): about_tab = QWidget() about_layout = QVBoxLayout() @@ -1319,6 +1393,9 @@ class SpotiFLACGUI(QWidget): return try: + if hasattr(self, 'fix_error_btn') and self.fix_error_btn.isVisible(): + self.fix_error_btn.hide() + self.reset_state() self.reset_ui() @@ -1355,6 +1432,39 @@ class SpotiFLACGUI(QWidget): def on_metadata_error(self, error_message): self.log_output.append(f'Error: {error_message}') + + if "Failed to get raw data" in error_message or "Failed to fetch secrets" in error_message or "Failed to get access token" in error_message: + if not hasattr(self, 'fix_error_btn') or not self.fix_error_btn.isVisible(): + self.show_fix_error_button() + + def show_fix_error_button(self): + if hasattr(self, 'fix_error_btn'): + self.fix_error_btn.show() + + def fix_error_action(self): + self.fix_error_btn.setEnabled(False) + self.fix_error_btn.setText("Fixing...") + + self.scrape_worker = SecretScrapeWorker() + self.scrape_worker.progress.connect(lambda msg: self.log_output.append(msg)) + self.scrape_worker.finished.connect(self.on_scrape_finished) + self.scrape_worker.start() + + def on_scrape_finished(self, success, message): + self.log_output.append(message) + + if hasattr(self, 'fix_error_btn'): + self.fix_error_btn.setEnabled(True) + self.fix_error_btn.setText("Fix Error") + + if success: + self.fix_error_btn.hide() + + if success: + url = self.spotify_url.text().strip() + if url: + self.log_output.append("Retrying fetch...") + QTimer.singleShot(1000, self.fetch_tracks) def handle_track_metadata(self, track_data): track_id = track_data["external_urls"].split("/")[-1] @@ -1604,37 +1714,27 @@ class SpotiFLACGUI(QWidget): def update_button_states(self): if self.is_single_track: - for btn in [self.download_selected_btn, self.download_all_btn, self.remove_btn, self.clear_btn]: + for btn in [self.download_btn, self.delete_btn]: btn.hide() self.single_track_container.show() self.single_download_btn.setEnabled(True) - self.single_clear_btn.setEnabled(True) + self.single_delete_btn.setEnabled(True) else: self.single_track_container.hide() - self.download_selected_btn.show() - self.download_all_btn.show() - self.remove_btn.show() - self.clear_btn.show() + self.download_btn.show() + self.delete_btn.show() - self.download_all_btn.setText('Download All') - self.clear_btn.setText('Clear') - - self.download_all_btn.setMinimumWidth(120) - self.clear_btn.setMinimumWidth(120) - - self.download_selected_btn.setEnabled(True) - self.download_all_btn.setEnabled(True) + self.download_btn.setEnabled(True) + self.delete_btn.setEnabled(True) def hide_track_buttons(self): buttons = [ - self.download_selected_btn, - self.download_all_btn, - self.remove_btn, - self.clear_btn + self.download_btn, + self.delete_btn ] for btn in buttons: btn.hide() @@ -1642,24 +1742,28 @@ class SpotiFLACGUI(QWidget): if hasattr(self, 'single_track_container'): self.single_track_container.hide() - def download_selected(self): + def download_tracks_action(self): if self.is_single_track: - self.download_all() + self.start_download([0]) else: - selected_items = self.track_list.selectedItems() + selected_items = self.track_list.selectedItems() + if not selected_items: - self.log_output.append('Warning: Please select tracks to download.') - return - selected_indices = [self.track_list.row(item) for item in selected_items] - self.download_tracks(selected_indices) - - def download_all(self): - if self.is_single_track: - self.download_tracks([0]) - else: - self.download_tracks(range(len(self.tracks))) - - def download_tracks(self, indices): + reply = QMessageBox.question( + self, + 'Confirm Download All', + f'No tracks selected. Download all {len(self.tracks)} tracks?', + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.Yes: + self.start_download(range(len(self.tracks))) + else: + selected_indices = [self.track_list.row(item) for item in selected_items] + self.start_download(selected_indices) + + def start_download(self, indices): self.log_output.clear() raw_outpath = self.output_dir.text().strip() outpath = os.path.normpath(raw_outpath) @@ -1703,13 +1807,12 @@ class SpotiFLACGUI(QWidget): self.update_ui_for_download_start() def update_ui_for_download_start(self): - self.download_selected_btn.setEnabled(False) - self.download_all_btn.setEnabled(False) + self.download_btn.setEnabled(False) if hasattr(self, 'single_download_btn'): self.single_download_btn.setEnabled(False) - if hasattr(self, 'single_clear_btn'): - self.single_clear_btn.setEnabled(False) + if hasattr(self, 'single_delete_btn'): + self.single_delete_btn.setEnabled(False) self.stop_btn.show() self.pause_resume_btn.show() @@ -1747,13 +1850,12 @@ class SpotiFLACGUI(QWidget): else: self.remove_successful_btn.hide() - self.download_selected_btn.setEnabled(True) - self.download_all_btn.setEnabled(True) + self.download_btn.setEnabled(True) if hasattr(self, 'single_download_btn'): self.single_download_btn.setEnabled(True) - if hasattr(self, 'single_clear_btn'): - self.single_clear_btn.setEnabled(True) + if hasattr(self, 'single_delete_btn'): + self.single_delete_btn.setEnabled(True) if success: self.log_output.append(f"\nStatus: {message}") @@ -1830,26 +1932,36 @@ class SpotiFLACGUI(QWidget): self.remove_successful_btn.hide() - def remove_selected_tracks(self): - if not self.is_single_track: + def delete_tracks(self): + if self.is_single_track: + self.reset_state() + self.reset_ui() + else: selected_items = self.track_list.selectedItems() - selected_indices = [self.track_list.row(item) for item in selected_items] - tracks_to_remove = [self.tracks[i] for i in selected_indices] - - for track in tracks_to_remove: - if track in self.tracks: - self.tracks.remove(track) - if track in self.all_tracks: - self.all_tracks.remove(track) - - - - self.update_track_list_display() - - def clear_tracks(self): - self.reset_state() - self.reset_ui() + if not selected_items: + reply = QMessageBox.question( + self, + 'Confirm Delete All', + f'No tracks selected. Delete all {len(self.tracks)} tracks?', + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.Yes: + self.reset_state() + self.reset_ui() + else: + selected_indices = [self.track_list.row(item) for item in selected_items] + tracks_to_remove = [self.tracks[i] for i in selected_indices] + + for track in tracks_to_remove: + if track in self.tracks: + self.tracks.remove(track) + if track in self.all_tracks: + self.all_tracks.remove(track) + + self.update_track_list_display() self.tab_widget.setCurrentIndex(0) def start_timer(self): diff --git a/getMetadata.py b/getMetadata.py index c9ff223..3132535 100644 --- a/getMetadata.py +++ b/getMetadata.py @@ -1,5 +1,6 @@ from time import sleep from urllib.parse import urlparse, parse_qs +from pathlib import Path import requests import json import time @@ -14,19 +15,32 @@ def get_random_user_agent(): # https://github.com/xyloflake/spot-secrets-go def generate_totp(): - url = "https://raw.githubusercontent.com/afkarxyz/secretBytes/refs/heads/main/secrets/secretBytes.json" + local_path = Path.home() / ".spotify-secret" / "secretBytes.json" + used_local = False try: + url = "https://raw.githubusercontent.com/afkarxyz/secretBytes/refs/heads/main/secrets/secretBytes.json" resp = requests.get(url, timeout=10) if resp.status_code != 200: - raise Exception(f"Failed to fetch TOTP secrets from GitHub. Status: {resp.status_code}") + raise Exception(f"GitHub fetch failed with status: {resp.status_code}") secrets_list = resp.json() - + except Exception as github_error: + try: + if local_path.exists(): + with open(local_path, 'r') as f: + secrets_list = json.load(f) + used_local = True + else: + raise Exception(f"GitHub failed ({github_error}) and no local file found at {local_path}") + except Exception as local_error: + raise Exception(f"Failed to fetch secrets from both GitHub and local: {local_error}") + + try: latest_entry = max(secrets_list, key=lambda x: x["version"]) version = latest_entry["version"] secret_cipher = latest_entry["secret"] except Exception as e: - raise Exception(f"Failed to fetch secrets from GitHub: {str(e)}") + raise Exception(f"Failed to process secrets: {str(e)}") processed = [byte ^ ((i % 33) + 9) for i, byte in enumerate(secret_cipher)] processed_str = "".join(map(str, processed)) diff --git a/getSecret.py b/getSecret.py new file mode 100644 index 0000000..35a3d36 --- /dev/null +++ b/getSecret.py @@ -0,0 +1,111 @@ +import json +import time +from pathlib import Path +from DrissionPage import ChromiumPage, ChromiumOptions + +def summarise(caps): + real = {} + for cap in caps: + sec = cap.get("secret") + if not sec or not isinstance(sec, str): + continue + ver = cap.get("version") or cap.get("obj", {}).get("version") + if ver and ver != 0: + real[str(int(ver))] = sec + + if not real: + return False, "No secrets found." + + versions = sorted(int(k) for k in real.keys()) + secret_bytes = [ + {"version": v, "secret": [ord(c) for c in real[str(v)]]} + for v in versions + ] + + secrets_dir = Path.home() / ".spotify-secret" + secrets_dir.mkdir(exist_ok=True) + + output_file = secrets_dir / "secretBytes.json" + with open(output_file, "w") as f: + json.dump(secret_bytes, f, indent=2) + + return True, f"Saved to: {output_file}" + + +def grab_live(progress_callback=None): + """ + Grab secrets from Spotify web player + Args: + progress_callback: Optional callback function to report progress + Returns: + list: Captured secrets + """ + def emit_progress(msg): + if progress_callback: + progress_callback(msg) + else: + print(msg) + + stealth = """(()=>{ + Object.defineProperty(navigator,'webdriver',{get:()=>false}); + Object.defineProperty(navigator,'languages',{get:()=>['en-US','en']}); + Object.defineProperty(navigator,'plugins',{get:()=>[1,2,3,4,5]}); + window.chrome={runtime:{}}; + const q=navigator.permissions.query; + navigator.permissions.query=p=>p.name==='notifications'?Promise.resolve({state:Notification.permission}):q(p); + const g=WebGLRenderingContext.prototype.getParameter; + WebGLRenderingContext.prototype.getParameter=function(p){ + if(p===37445)return'Intel Inc.';if(p===37446)return'Intel Iris OpenGL Engine';return g.call(this,p); + }; + })();""" + + hook = """(()=>{if(globalThis.__secretHookInstalled)return; + globalThis.__secretHookInstalled=true;globalThis.__captures=[]; + Object.defineProperty(Object.prototype,'secret',{configurable:true,set:function(v){ + try{__captures.push({secret:v,version:this.version,obj:this});}catch(e){} + Object.defineProperty(this,'secret',{value:v,writable:true,configurable:true,enumerable:true});}}); + })();""" + + co = ChromiumOptions() + co.headless(True) + co.set_argument('--disable-blink-features=AutomationControlled') + co.set_argument('--no-sandbox') + + page = ChromiumPage(addr_or_opts=co) + try: + page.run_cdp('Page.addScriptToEvaluateOnNewDocument', source=stealth) + page.run_cdp('Page.addScriptToEvaluateOnNewDocument', source=hook) + emit_progress("Opening Spotify...") + page.get("https://open.spotify.com") + time.sleep(3) + caps = page.run_js("return globalThis.__captures || []") + for c in caps: + if isinstance(c, dict) and c.get("secret") and c.get("version"): + emit_progress(f"Secret({int(c['version'])}): {c['secret']}") + return caps or [] + finally: + page.quit() + +def scrape_and_save(progress_callback=None): + """ + Main function to scrape secrets and save to file + Args: + progress_callback: Optional callback function to report progress + Returns: + tuple: (success: bool, message: str) + """ + try: + caps = grab_live(progress_callback) + return summarise(caps) + except Exception as e: + return False, f"Error: {str(e)}" + + +def main(): + success, message = scrape_and_save() + print(message) + return 0 if success else 1 + +if __name__ == "__main__": + import sys + sys.exit(main()) \ No newline at end of file diff --git a/icons/circle-x.svg b/icons/circle-x.svg new file mode 100644 index 0000000..3625f95 --- /dev/null +++ b/icons/circle-x.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/deezer.png b/icons/deezer.png similarity index 100% rename from deezer.png rename to icons/deezer.png diff --git a/icons/download.svg b/icons/download.svg new file mode 100644 index 0000000..c77f62c --- /dev/null +++ b/icons/download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/icon.ico b/icons/icon.ico similarity index 100% rename from icon.ico rename to icons/icon.ico diff --git a/icon.svg b/icons/icon.svg similarity index 100% rename from icon.svg rename to icons/icon.svg diff --git a/tidal.png b/icons/tidal.png similarity index 100% rename from tidal.png rename to icons/tidal.png diff --git a/icons/tool.svg b/icons/tool.svg new file mode 100644 index 0000000..29229f9 --- /dev/null +++ b/icons/tool.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/trash.svg b/icons/trash.svg new file mode 100644 index 0000000..20fb5cb --- /dev/null +++ b/icons/trash.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/requirements.txt b/requirements.txt index 2ae380a..4dcf0fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ requests mutagen pyotp packaging -pyinstaller \ No newline at end of file +pyinstaller +DrissionPage \ No newline at end of file