Compare commits

...

18 Commits

Author SHA1 Message Date
afkarxyz a620c16b1c v5.3 2025-10-22 05:13:44 +07:00
afkarxyz cf27ae098d v5.2 2025-10-21 03:26:31 +07:00
afkarxyz a0c60a473a . 2025-10-21 03:26:01 +07:00
afkarxyz 25c5a4d175 v5.2 2025-10-21 03:23:28 +07:00
afkarxyz 33a6137f75 v5.1 2025-10-21 02:16:31 +07:00
afkarxyz b4fcb6bca6 v5.1 2025-10-21 02:11:41 +07:00
afkarxyz 5ab19a6d37 . 2025-10-21 02:10:50 +07:00
afkarxyz 8547e6d410 v5.0 2025-10-13 05:37:10 +07:00
afkarxyz 17666d8027 Update version.json 2025-10-13 05:16:51 +07:00
afkarxyz ab208482ca v5.0 2025-10-13 05:09:53 +07:00
afkarxyz 76e02d77e8 v5.0 2025-10-13 05:05:01 +07:00
afkarxyz 75cc4543ad Merge pull request #65 from petacz/patch-1
Use Embedded ISRC Tags to check for existing files
2025-10-13 04:55:34 +07:00
Petr V 0b468c4b60 Use Embedded ISRC Tags to check for existing files 2025-10-12 19:53:28 +02:00
afkarxyz 87a6a778f7 v4.9 2025-10-12 00:30:15 +07:00
afkarxyz ef893ab9f4 v4.9 2025-10-12 00:26:01 +07:00
afkarxyz 3eda3245ca v4.9 2025-10-12 00:23:26 +07:00
afkarxyz f6f238361c v4.9 2025-10-12 00:22:30 +07:00
afkarxyz 998730bbb3 v4.8 2025-10-11 18:18:27 +07:00
15 changed files with 676 additions and 175 deletions
+5 -5
View File
@@ -6,17 +6,17 @@
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Tidal & Deezer. <b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Tidal & Deezer.
</div> </div>
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.7/SpotiFLAC.exe) ### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v5.2/SpotiFLAC.exe)
## Screenshots ## Screenshots
![image](https://github.com/user-attachments/assets/180b8322-ce2d-4842-a5dd-ac4d7b7a5efa) ![image](https://github.com/user-attachments/assets/416fca55-b885-45c0-af2a-b8b7ce5d5ef3)
![image](https://github.com/user-attachments/assets/3f84d53b-2da1-4488-986c-772b82832f2d) ![image](https://github.com/user-attachments/assets/f9b11da4-dbc3-435e-8954-5627ebe2ccdc)
![image](https://github.com/user-attachments/assets/f604dc04-4ee6-4084-b314-0be7cd5d7ef9) ![image](https://github.com/user-attachments/assets/7507e58d-e228-4edf-adf7-675731731019)
![image](https://github.com/user-attachments/assets/1c3beda2-236b-4452-8afd-a2dfedf389e5) ![image](https://github.com/user-attachments/assets/e81f69f3-1552-4c12-a4e3-2f6d4b322d84)
## Lossless Audio Check ## Lossless Audio Check
+387 -123
View File
@@ -6,22 +6,26 @@ from pathlib import Path
import requests import requests
import re import re
import asyncio import asyncio
import json
from packaging import version from packaging import version
import qdarktheme import qdarktheme
from mutagen.flac import FLAC
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit, QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit,
QLabel, QFileDialog, QListWidget, QTextEdit, QTabWidget, QButtonGroup, QRadioButton, QLabel, QFileDialog, QListWidget, QTextEdit, QTabWidget, QButtonGroup, QRadioButton,
QAbstractItemView, QProgressBar, QCheckBox, QDialog, 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.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 PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
from getMetadata import get_filtered_data, parse_uri, SpotifyInvalidUrlException from getMetadata import get_filtered_data, parse_uri, SpotifyInvalidUrlException
from tidalDL import TidalDownloader from tidalDL import TidalDownloader
from deezerDL import DeezerDownloader from deezerDL import DeezerDownloader
from getSecret import scrape_and_save
@dataclass @dataclass
class Track: class Track:
@@ -35,6 +39,25 @@ class Track:
isrc: str = "" isrc: str = ""
release_date: 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): class MetadataFetchWorker(QThread):
finished = pyqtSignal(dict) finished = pyqtSignal(dict)
error = pyqtSignal(str) error = pyqtSignal(str)
@@ -61,7 +84,7 @@ class DownloadWorker(QThread):
def __init__(self, tracks, outpath, is_single_track=False, is_album=False, is_playlist=False, def __init__(self, tracks, outpath, is_single_track=False, is_album=False, is_playlist=False,
album_or_playlist_name='', filename_format='title_artist', use_track_numbers=True, album_or_playlist_name='', filename_format='title_artist', use_track_numbers=True,
use_artist_subfolders=False, use_album_subfolders=False, service="tidal"): use_artist_subfolders=False, use_album_subfolders=False, service="tidal", tidal_api_url=None):
super().__init__() super().__init__()
self.tracks = tracks self.tracks = tracks
self.outpath = outpath self.outpath = outpath
@@ -74,12 +97,22 @@ class DownloadWorker(QThread):
self.use_artist_subfolders = use_artist_subfolders self.use_artist_subfolders = use_artist_subfolders
self.use_album_subfolders = use_album_subfolders self.use_album_subfolders = use_album_subfolders
self.service = service self.service = service
self.tidal_api_url = tidal_api_url
self.is_paused = False self.is_paused = False
self.is_stopped = False self.is_stopped = False
self.failed_tracks = [] self.failed_tracks = []
self.successful_tracks = [] self.successful_tracks = []
self.skipped_tracks = [] self.skipped_tracks = []
def get_flac_isrc(self, filepath):
try:
audio = FLAC(filepath)
if 'isrc' in audio:
return audio['isrc'][0]
except Exception:
pass
return None
def get_formatted_filename(self, track): def get_formatted_filename(self, track):
if self.filename_format == "artist_title": if self.filename_format == "artist_title":
filename = f"{track.artists} - {track.title}.flac" filename = f"{track.artists} - {track.title}.flac"
@@ -92,11 +125,11 @@ class DownloadWorker(QThread):
def run(self): def run(self):
try: try:
if self.service == "tidal": if self.service == "tidal":
downloader = TidalDownloader() downloader = TidalDownloader(api_url=self.tidal_api_url)
elif self.service == "deezer": elif self.service == "deezer":
downloader = DeezerDownloader() downloader = DeezerDownloader()
else: else:
downloader = TidalDownloader() downloader = TidalDownloader(api_url=self.tidal_api_url)
def progress_update(current, total): def progress_update(current, total):
if total <= 0: if total <= 0:
@@ -124,16 +157,39 @@ class DownloadWorker(QThread):
if self.use_artist_subfolders: if self.use_artist_subfolders:
artist_name = track.artists.split(', ')[0] if ', ' in track.artists else track.artists artist_name = track.artists.split(', ')[0] if ', ' in track.artists else track.artists
artist_folder = re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', artist_name) artist_folder = re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', artist_name)
artist_folder = artist_folder.rstrip('. ')
track_outpath = os.path.join(track_outpath, artist_folder) track_outpath = os.path.join(track_outpath, artist_folder)
if self.use_album_subfolders: if self.use_album_subfolders:
album_folder = re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', track.album) album_folder = re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', track.album)
album_folder = album_folder.rstrip('. ')
track_outpath = os.path.join(track_outpath, album_folder) track_outpath = os.path.join(track_outpath, album_folder)
os.makedirs(track_outpath, exist_ok=True) os.makedirs(track_outpath, exist_ok=True)
else: else:
track_outpath = self.outpath track_outpath = self.outpath
spotify_isrc = track.isrc
if spotify_isrc:
is_already_downloaded = False
try:
for filename in os.listdir(track_outpath):
if filename.lower().endswith('.flac'):
filepath = os.path.join(track_outpath, filename)
local_isrc = self.get_flac_isrc(filepath)
if local_isrc and local_isrc == spotify_isrc:
self.progress.emit(f"Skipped: Track with matching ISRC '{spotify_isrc}' already exists ('{filename}').", 0)
self.progress.emit(f"Skipped: {track.title} - {track.artists}",
int((i + 1) / total_tracks * 100))
self.skipped_tracks.append(track)
is_already_downloaded = True
break
except FileNotFoundError:
pass
if is_already_downloaded:
continue
if (self.is_album or self.is_playlist) and self.use_track_numbers: if (self.is_album or self.is_playlist) and self.use_track_numbers:
new_filename = f"{track.track_number:02d} - {self.get_formatted_filename(track)}" new_filename = f"{track.track_number:02d} - {self.get_formatted_filename(track)}"
else: else:
@@ -143,7 +199,7 @@ class DownloadWorker(QThread):
new_filepath = os.path.join(track_outpath, new_filename) new_filepath = os.path.join(track_outpath, new_filename)
if os.path.exists(new_filepath) and os.path.getsize(new_filepath) > 0: if os.path.exists(new_filepath) and os.path.getsize(new_filepath) > 0:
self.progress.emit(f"File already exists: {new_filename}. Skipping download.", 0) self.progress.emit(f"File already exists by name: {new_filename}. Skipping download.", 0)
self.progress.emit(f"Skipped: {track.title} - {track.artists}", self.progress.emit(f"Skipped: {track.title} - {track.artists}",
int((i + 1) / total_tracks * 100)) int((i + 1) / total_tracks * 100))
self.skipped_tracks.append(track) self.skipped_tracks.append(track)
@@ -159,13 +215,16 @@ class DownloadWorker(QThread):
is_paused_callback = lambda: self.is_paused is_paused_callback = lambda: self.is_paused
is_stopped_callback = lambda: self.is_stopped is_stopped_callback = lambda: self.is_stopped
auto_fallback = (self.tidal_api_url == "auto")
download_result_details = downloader.download( download_result_details = downloader.download(
query=f"{track.title} {track.artists}", query=f"{track.title} {track.artists}",
isrc=track.isrc, isrc=track.isrc,
output_dir=track_outpath, output_dir=track_outpath,
quality="LOSSLESS", quality="LOSSLESS",
is_paused_callback=is_paused_callback, is_paused_callback=is_paused_callback,
is_stopped_callback=is_stopped_callback is_stopped_callback=is_stopped_callback,
auto_fallback=auto_fallback
) )
if isinstance(download_result_details, str) and os.path.exists(download_result_details): if isinstance(download_result_details, str) and os.path.exists(download_result_details):
@@ -316,33 +375,7 @@ class UpdateDialog(QDialog):
self.update_button.clicked.connect(self.accept) self.update_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject) self.cancel_button.clicked.connect(self.reject)
class TidalStatusChecker(QThread): class ServiceStatusDelegate(QStyledItemDelegate):
status_updated = pyqtSignal(bool)
error = pyqtSignal(str)
def run(self):
try:
response = requests.get("https://tidal.401658.xyz", timeout=5)
is_online = response.status_code == 200 or response.status_code == 429
self.status_updated.emit(is_online)
except Exception as e:
self.error.emit(f"Error checking Tidal (API) status: {str(e)}")
self.status_updated.emit(False)
class DeezerStatusChecker(QThread):
status_updated = pyqtSignal(bool)
error = pyqtSignal(str)
def run(self):
try:
response = requests.get("https://deezmate.com/", timeout=5)
is_online = response.status_code == 200
self.status_updated.emit(is_online)
except Exception as e:
self.error.emit(f"Error checking Deezer status: {str(e)}")
self.status_updated.emit(False)
class StatusIndicatorDelegate(QStyledItemDelegate):
def paint(self, painter, option, index): def paint(self, painter, option, index):
item_data = index.data(Qt.ItemDataRole.UserRole) item_data = index.data(Qt.ItemDataRole.UserRole)
is_online = item_data.get('online', False) if item_data else False is_online = item_data.get('online', False) if item_data else False
@@ -361,17 +394,61 @@ class StatusIndicatorDelegate(QStyledItemDelegate):
painter.drawEllipse(circle_x, circle_y, circle_size, circle_size) painter.drawEllipse(circle_x, circle_y, circle_size, circle_size)
painter.restore() painter.restore()
class TidalAPIDelegate(QStyledItemDelegate):
def paint(self, painter, option, index):
item_data = index.data(Qt.ItemDataRole.UserRole + 1)
super().paint(painter, option, index)
if item_data and isinstance(item_data, dict) and 'status' in item_data:
is_online = item_data.get('status') == 'UP'
indicator_color = Qt.GlobalColor.green if is_online else Qt.GlobalColor.red
circle_size = 6
circle_y = option.rect.center().y() - circle_size // 2
circle_x = option.rect.right() - circle_size - 5
painter.save()
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QBrush(indicator_color))
painter.drawEllipse(circle_x, circle_y, circle_size, circle_size)
painter.restore()
class TidalStatusChecker(QThread):
status_updated = pyqtSignal(bool)
error = pyqtSignal(str)
def run(self):
try:
response = requests.get("https://status.monochrome.tf", timeout=5)
is_online = response.status_code == 200
self.status_updated.emit(is_online)
except Exception as e:
self.error.emit(f"Error checking Tidal status: {str(e)}")
self.status_updated.emit(False)
class DeezerStatusChecker(QThread):
status_updated = pyqtSignal(bool)
error = pyqtSignal(str)
def run(self):
try:
response = requests.get("https://deezmate.com/", timeout=5)
is_online = response.status_code == 200
self.status_updated.emit(is_online)
except Exception as e:
self.error.emit(f"Error checking Deezer status: {str(e)}")
self.status_updated.emit(False)
class ServiceComboBox(QComboBox): class ServiceComboBox(QComboBox):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.setIconSize(QSize(16, 16)) self.setIconSize(QSize(16, 16))
self.services_status = {} self.setItemDelegate(ServiceStatusDelegate())
self.setItemDelegate(StatusIndicatorDelegate())
self.setup_items() self.setup_items()
self.tidal_status_checker = TidalStatusChecker() self.tidal_status_checker = TidalStatusChecker()
self.tidal_status_checker.status_updated.connect(self.update_tidal_service_status) self.tidal_status_checker.status_updated.connect(self.update_tidal_status)
self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}")) self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}"))
self.tidal_status_checker.start() self.tidal_status_checker.start()
@@ -380,7 +457,7 @@ class ServiceComboBox(QComboBox):
self.tidal_status_timer.start(60000) self.tidal_status_timer.start(60000)
self.deezer_status_checker = DeezerStatusChecker() self.deezer_status_checker = DeezerStatusChecker()
self.deezer_status_checker.status_updated.connect(self.update_deezer_service_status) self.deezer_status_checker.status_updated.connect(self.update_deezer_status)
self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}")) self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}"))
self.deezer_status_checker.start() self.deezer_status_checker.start()
@@ -392,8 +469,8 @@ class ServiceComboBox(QComboBox):
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
self.services = [ self.services = [
{'id': 'tidal', 'name': 'Tidal', 'icon': 'tidal.png', 'online': False}, {'id': 'tidal', 'name': 'Tidal', 'icon': 'icons/tidal.png', 'online': False},
{'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png', 'online': False} {'id': 'deezer', 'name': 'Deezer', 'icon': 'icons/deezer.png', 'online': False}
] ]
for service in self.services: for service in self.services:
@@ -412,10 +489,10 @@ class ServiceComboBox(QComboBox):
pixmap.fill(Qt.GlobalColor.transparent) pixmap.fill(Qt.GlobalColor.transparent)
pixmap.save(path) pixmap.save(path)
def update_service_status(self, service_id, is_online): def update_tidal_status(self, is_online):
for i in range(self.count()): for i in range(self.count()):
current_service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1) service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
if current_service_id == service_id: if service_id == 'tidal':
service_data = self.itemData(i, Qt.ItemDataRole.UserRole) service_data = self.itemData(i, Qt.ItemDataRole.UserRole)
if isinstance(service_data, dict): if isinstance(service_data, dict):
service_data['online'] = is_online service_data['online'] = is_online
@@ -423,21 +500,26 @@ class ServiceComboBox(QComboBox):
break break
self.update() self.update()
def update_tidal_service_status(self, is_online):
self.update_service_status('tidal', is_online)
def refresh_tidal_status(self): def refresh_tidal_status(self):
if hasattr(self, 'tidal_status_checker') and self.tidal_status_checker.isRunning(): if hasattr(self, 'tidal_status_checker') and self.tidal_status_checker.isRunning():
self.tidal_status_checker.quit() self.tidal_status_checker.quit()
self.tidal_status_checker.wait() self.tidal_status_checker.wait()
self.tidal_status_checker = TidalStatusChecker() self.tidal_status_checker = TidalStatusChecker()
self.tidal_status_checker.status_updated.connect(self.update_tidal_service_status) self.tidal_status_checker.status_updated.connect(self.update_tidal_status)
self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}")) self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}"))
self.tidal_status_checker.start() self.tidal_status_checker.start()
def update_deezer_service_status(self, is_online): def update_deezer_status(self, is_online):
self.update_service_status('deezer', is_online) for i in range(self.count()):
service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
if service_id == 'deezer':
service_data = self.itemData(i, Qt.ItemDataRole.UserRole)
if isinstance(service_data, dict):
service_data['online'] = is_online
self.setItemData(i, service_data, Qt.ItemDataRole.UserRole)
break
self.update()
def refresh_deezer_status(self): def refresh_deezer_status(self):
if hasattr(self, 'deezer_status_checker') and self.deezer_status_checker.isRunning(): if hasattr(self, 'deezer_status_checker') and self.deezer_status_checker.isRunning():
@@ -445,7 +527,7 @@ class ServiceComboBox(QComboBox):
self.deezer_status_checker.wait() self.deezer_status_checker.wait()
self.deezer_status_checker = DeezerStatusChecker() self.deezer_status_checker = DeezerStatusChecker()
self.deezer_status_checker.status_updated.connect(self.update_deezer_service_status) self.deezer_status_checker.status_updated.connect(self.update_deezer_status)
self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}")) self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}"))
self.deezer_status_checker.start() self.deezer_status_checker.start()
@@ -455,7 +537,7 @@ class ServiceComboBox(QComboBox):
class SpotiFLACGUI(QWidget): class SpotiFLACGUI(QWidget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.current_version = "4.8" self.current_version = "5.3"
self.tracks = [] self.tracks = []
self.all_tracks = [] self.all_tracks = []
self.successful_downloads = [] self.successful_downloads = []
@@ -470,6 +552,7 @@ class SpotiFLACGUI(QWidget):
self.use_artist_subfolders = self.settings.value('use_artist_subfolders', False, type=bool) self.use_artist_subfolders = self.settings.value('use_artist_subfolders', False, type=bool)
self.use_album_subfolders = self.settings.value('use_album_subfolders', False, type=bool) self.use_album_subfolders = self.settings.value('use_album_subfolders', False, type=bool)
self.service = self.settings.value('service', 'tidal') self.service = self.settings.value('service', 'tidal')
self.tidal_api = self.settings.value('tidal_api', 'https://hifi.401658.xyz')
self.check_for_updates = self.settings.value('check_for_updates', True, type=bool) self.check_for_updates = self.settings.value('check_for_updates', True, type=bool)
self.current_theme_color = self.settings.value('theme_color', '#2196F3') self.current_theme_color = self.settings.value('theme_color', '#2196F3')
self.track_list_format = self.settings.value('track_list_format', 'track_artist_date_duration') self.track_list_format = self.settings.value('track_list_format', 'track_artist_date_duration')
@@ -530,6 +613,7 @@ class SpotiFLACGUI(QWidget):
def reset_ui(self): def reset_ui(self):
self.track_list.clear() self.track_list.clear()
self.track_list.show()
self.log_output.clear() self.log_output.clear()
self.progress_bar.setValue(0) self.progress_bar.setValue(0)
self.progress_bar.hide() self.progress_bar.hide()
@@ -543,12 +627,32 @@ class SpotiFLACGUI(QWidget):
if hasattr(self, 'search_widget'): if hasattr(self, 'search_widget'):
self.search_widget.hide() 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): def initUI(self):
self.setWindowTitle('SpotiFLAC') self.setWindowTitle('SpotiFLAC')
self.setFixedWidth(650) self.setFixedWidth(650)
self.setMinimumHeight(350) 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): if os.path.exists(icon_path):
self.setWindowIcon(QIcon(icon_path)) self.setWindowIcon(QIcon(icon_path))
@@ -766,23 +870,21 @@ class SpotiFLACGUI(QWidget):
def setup_track_buttons(self): def setup_track_buttons(self):
self.btn_layout = QHBoxLayout() self.btn_layout = QHBoxLayout()
self.download_selected_btn = QPushButton('Download Selected') self.download_btn = QPushButton(' Download')
self.download_all_btn = QPushButton('Download All') self.download_btn.setIcon(self.get_themed_icon('download.svg'))
self.remove_btn = QPushButton('Remove Selected') self.delete_btn = QPushButton(' Delete')
self.clear_btn = QPushButton('Clear') 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]: for btn in [self.download_btn, self.delete_btn]:
btn.setMinimumWidth(120) btn.setFixedWidth(120)
btn.setCursor(Qt.CursorShape.PointingHandCursor) btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.download_selected_btn.clicked.connect(self.download_selected) self.download_btn.clicked.connect(self.download_tracks_action)
self.download_all_btn.clicked.connect(self.download_all) self.delete_btn.clicked.connect(self.delete_tracks)
self.remove_btn.clicked.connect(self.remove_selected_tracks)
self.clear_btn.clicked.connect(self.clear_tracks)
self.btn_layout.addStretch() 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(self.download_btn)
self.btn_layout.addWidget(btn, 1) self.btn_layout.addWidget(self.delete_btn)
self.btn_layout.addStretch() self.btn_layout.addStretch()
self.single_track_container = QWidget() self.single_track_container = QWidget()
@@ -790,18 +892,20 @@ class SpotiFLACGUI(QWidget):
single_track_layout.setContentsMargins(0, 0, 0, 0) single_track_layout.setContentsMargins(0, 0, 0, 0)
self.single_download_btn = QPushButton(' Download') self.single_download_btn = QPushButton(' Download')
self.single_clear_btn = QPushButton('Clear') 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.setFixedWidth(120)
btn.setCursor(Qt.CursorShape.PointingHandCursor) btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.single_download_btn.clicked.connect(self.download_all) self.single_download_btn.clicked.connect(self.download_tracks_action)
self.single_clear_btn.clicked.connect(self.clear_tracks) self.single_delete_btn.clicked.connect(self.delete_tracks)
single_track_layout.addStretch() single_track_layout.addStretch()
single_track_layout.addWidget(self.single_download_btn) 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() single_track_layout.addStretch()
self.single_track_container.hide() self.single_track_container.hide()
@@ -815,6 +919,18 @@ class SpotiFLACGUI(QWidget):
self.log_output.setReadOnly(True) self.log_output.setReadOnly(True)
process_layout.addWidget(self.log_output) 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 = QVBoxLayout()
progress_time_layout.setSpacing(2) progress_time_layout.setSpacing(2)
@@ -840,7 +956,8 @@ class SpotiFLACGUI(QWidget):
self.stop_btn.clicked.connect(self.stop_download) self.stop_btn.clicked.connect(self.stop_download)
self.pause_resume_btn.clicked.connect(self.toggle_pause_resume) 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.setFixedWidth(200)
self.remove_successful_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.remove_successful_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.remove_successful_btn.clicked.connect(self.remove_successful_downloads) self.remove_successful_btn.clicked.connect(self.remove_successful_downloads)
@@ -1023,17 +1140,43 @@ class SpotiFLACGUI(QWidget):
auth_label.setStyleSheet("font-weight: bold; margin-top: 8px; margin-bottom: 5px;") auth_label.setStyleSheet("font-weight: bold; margin-top: 8px; margin-bottom: 5px;")
auth_layout.addWidget(auth_label) auth_layout.addWidget(auth_label)
service_fallback_layout = QHBoxLayout() service_api_layout = QHBoxLayout()
service_label = QLabel('Service:') service_label = QLabel('Service:')
service_label.setFixedWidth(53)
self.service_dropdown = ServiceComboBox() self.service_dropdown = ServiceComboBox()
self.service_dropdown.setFixedWidth(100)
self.service_dropdown.currentIndexChanged.connect(self.on_service_changed) self.service_dropdown.currentIndexChanged.connect(self.on_service_changed)
service_fallback_layout.addWidget(service_label)
service_fallback_layout.addWidget(self.service_dropdown)
service_fallback_layout.addStretch() service_api_layout.addWidget(service_label)
auth_layout.addLayout(service_fallback_layout) service_api_layout.addWidget(self.service_dropdown)
service_api_layout.addSpacing(15)
self.tidal_api_label = QLabel('API Instances:')
self.tidal_api_label.setFixedWidth(85)
self.tidal_api_dropdown = QComboBox()
self.tidal_api_dropdown.setItemDelegate(TidalAPIDelegate())
self.tidal_api_dropdown.addItem("Default", "https://hifi.401658.xyz")
self.tidal_api_dropdown.addItem("Auto Fallback", "auto")
self.tidal_api_dropdown.currentIndexChanged.connect(self.on_tidal_api_changed)
self.refresh_api_btn = QPushButton('Refresh')
self.refresh_api_btn.setFixedWidth(80)
self.refresh_api_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.refresh_api_btn.clicked.connect(self.refresh_tidal_apis)
service_api_layout.addWidget(self.tidal_api_label)
service_api_layout.addWidget(self.tidal_api_dropdown, 1)
service_api_layout.addSpacing(5)
service_api_layout.addWidget(self.refresh_api_btn)
auth_layout.addLayout(service_api_layout)
self.refresh_tidal_apis()
self.update_tidal_api_visibility()
settings_layout.addWidget(auth_group) settings_layout.addWidget(auth_group)
settings_layout.addStretch() settings_layout.addStretch()
@@ -1043,6 +1186,8 @@ class SpotiFLACGUI(QWidget):
self.set_combobox_value(self.track_list_format_dropdown, self.track_list_format) self.set_combobox_value(self.track_list_format_dropdown, self.track_list_format)
self.set_combobox_value(self.date_format_dropdown, self.date_format) self.set_combobox_value(self.date_format_dropdown, self.date_format)
self.set_combobox_value(self.tidal_api_dropdown, self.tidal_api)
def setup_theme_tab(self): def setup_theme_tab(self):
theme_tab = QWidget() theme_tab = QWidget()
theme_layout = QVBoxLayout() theme_layout = QVBoxLayout()
@@ -1214,6 +1359,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): def setup_about_tab(self):
about_tab = QWidget() about_tab = QWidget()
about_layout = QVBoxLayout() about_layout = QVBoxLayout()
@@ -1256,6 +1420,55 @@ class SpotiFLACGUI(QWidget):
self.settings.setValue('service', service) self.settings.setValue('service', service)
self.settings.sync() self.settings.sync()
self.log_output.append(f"Service changed to: {self.service_dropdown.currentText()}") self.log_output.append(f"Service changed to: {self.service_dropdown.currentText()}")
self.update_tidal_api_visibility()
def update_tidal_api_visibility(self):
is_tidal = self.service_dropdown.currentData() == 'tidal'
self.tidal_api_label.setVisible(is_tidal)
self.tidal_api_dropdown.setVisible(is_tidal)
self.refresh_api_btn.setVisible(is_tidal)
def on_tidal_api_changed(self, index):
selected_api = self.tidal_api_dropdown.currentData()
if selected_api:
self.tidal_api = selected_api
self.settings.setValue('tidal_api', selected_api)
self.settings.sync()
self.log_output.append(f"API Instance changed to: {self.tidal_api_dropdown.currentText()}")
def refresh_tidal_apis(self):
try:
self.log_output.append("Fetching available API instances...")
apis = TidalDownloader.get_available_apis()
while self.tidal_api_dropdown.count() > 2:
self.tidal_api_dropdown.removeItem(2)
if apis:
for api in apis:
url = api.get('url', '')
uptime = api.get('uptime', 0)
avg_time = api.get('avg_response_time', 0)
status = "UP" if api.get('last_check', {}).get('success') else "DOWN"
domain = url.replace('https://', '').replace('http://', '')
label = f"{domain} ({uptime:.0f}%, {avg_time}ms)"
status_data = {
'status': status,
'uptime': uptime,
'avg_time': avg_time
}
self.tidal_api_dropdown.addItem(label, url)
item_index = self.tidal_api_dropdown.count() - 1
self.tidal_api_dropdown.setItemData(item_index, status_data, Qt.ItemDataRole.UserRole + 1)
self.log_output.append(f"Found {len(apis)} available API instances")
else:
self.log_output.append("No APIs found, using default")
except Exception as e:
self.log_output.append(f"Error fetching APIs: {str(e)}")
def save_url(self): def save_url(self):
self.settings.setValue('spotify_url', self.spotify_url.text().strip()) self.settings.setValue('spotify_url', self.spotify_url.text().strip())
@@ -1319,6 +1532,9 @@ class SpotiFLACGUI(QWidget):
return return
try: try:
if hasattr(self, 'fix_error_btn') and self.fix_error_btn.isVisible():
self.fix_error_btn.hide()
self.reset_state() self.reset_state()
self.reset_ui() self.reset_ui()
@@ -1356,6 +1572,39 @@ class SpotiFLACGUI(QWidget):
def on_metadata_error(self, error_message): def on_metadata_error(self, error_message):
self.log_output.append(f'Error: {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): def handle_track_metadata(self, track_data):
track_id = track_data["external_urls"].split("/")[-1] track_id = track_data["external_urls"].split("/")[-1]
@@ -1604,37 +1853,27 @@ class SpotiFLACGUI(QWidget):
def update_button_states(self): def update_button_states(self):
if self.is_single_track: 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() btn.hide()
self.single_track_container.show() self.single_track_container.show()
self.single_download_btn.setEnabled(True) self.single_download_btn.setEnabled(True)
self.single_clear_btn.setEnabled(True) self.single_delete_btn.setEnabled(True)
else: else:
self.single_track_container.hide() self.single_track_container.hide()
self.download_selected_btn.show() self.download_btn.show()
self.download_all_btn.show() self.delete_btn.show()
self.remove_btn.show()
self.clear_btn.show()
self.download_all_btn.setText('Download All') self.download_btn.setEnabled(True)
self.clear_btn.setText('Clear') self.delete_btn.setEnabled(True)
self.download_all_btn.setMinimumWidth(120)
self.clear_btn.setMinimumWidth(120)
self.download_selected_btn.setEnabled(True)
self.download_all_btn.setEnabled(True)
def hide_track_buttons(self): def hide_track_buttons(self):
buttons = [ buttons = [
self.download_selected_btn, self.download_btn,
self.download_all_btn, self.delete_btn
self.remove_btn,
self.clear_btn
] ]
for btn in buttons: for btn in buttons:
btn.hide() btn.hide()
@@ -1642,24 +1881,28 @@ class SpotiFLACGUI(QWidget):
if hasattr(self, 'single_track_container'): if hasattr(self, 'single_track_container'):
self.single_track_container.hide() self.single_track_container.hide()
def download_selected(self): def download_tracks_action(self):
if self.is_single_track: if self.is_single_track:
self.download_all() self.start_download([0])
else: else:
selected_items = self.track_list.selectedItems() selected_items = self.track_list.selectedItems()
if not selected_items: if not selected_items:
self.log_output.append('Warning: Please select tracks to download.') reply = QMessageBox.question(
return self,
selected_indices = [self.track_list.row(item) for item in selected_items] 'Confirm Download All',
self.download_tracks(selected_indices) f'No tracks selected. Download all {len(self.tracks)} tracks?',
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
def download_all(self): if reply == QMessageBox.StandardButton.Yes:
if self.is_single_track: self.start_download(range(len(self.tracks)))
self.download_tracks([0])
else: else:
self.download_tracks(range(len(self.tracks))) selected_indices = [self.track_list.row(item) for item in selected_items]
self.start_download(selected_indices)
def download_tracks(self, indices): def start_download(self, indices):
self.log_output.clear() self.log_output.clear()
raw_outpath = self.output_dir.text().strip() raw_outpath = self.output_dir.text().strip()
outpath = os.path.normpath(raw_outpath) outpath = os.path.normpath(raw_outpath)
@@ -1672,6 +1915,7 @@ class SpotiFLACGUI(QWidget):
if self.is_album or self.is_playlist: if self.is_album or self.is_playlist:
name = self.album_or_playlist_name.strip() name = self.album_or_playlist_name.strip()
folder_name = re.sub(r'[<>:"/\\|?*]', '_', name) folder_name = re.sub(r'[<>:"/\\|?*]', '_', name)
folder_name = folder_name.rstrip('. ')
outpath = os.path.join(outpath, folder_name) outpath = os.path.join(outpath, folder_name)
os.makedirs(outpath, exist_ok=True) os.makedirs(outpath, exist_ok=True)
@@ -1683,6 +1927,16 @@ class SpotiFLACGUI(QWidget):
def start_download_worker(self, tracks_to_download, outpath): def start_download_worker(self, tracks_to_download, outpath):
service = self.service_dropdown.currentData() service = self.service_dropdown.currentData()
tidal_api_url = None
if service == "tidal":
selected_api = self.tidal_api_dropdown.currentData()
if selected_api == "auto":
tidal_api_url = "auto"
self.log_output.append("Using auto fallback mode (will try multiple APIs)")
else:
tidal_api_url = selected_api
self.log_output.append(f"Using API: {selected_api}")
self.worker = DownloadWorker( self.worker = DownloadWorker(
tracks_to_download, tracks_to_download,
outpath, outpath,
@@ -1694,7 +1948,8 @@ class SpotiFLACGUI(QWidget):
self.use_track_numbers, self.use_track_numbers,
self.use_artist_subfolders, self.use_artist_subfolders,
self.use_album_subfolders, self.use_album_subfolders,
service service,
tidal_api_url
) )
self.worker.finished.connect(lambda success, message, failed_tracks, successful_tracks, skipped_tracks: self.on_download_finished(success, message, failed_tracks, successful_tracks, skipped_tracks)) self.worker.finished.connect(lambda success, message, failed_tracks, successful_tracks, skipped_tracks: self.on_download_finished(success, message, failed_tracks, successful_tracks, skipped_tracks))
self.worker.progress.connect(self.update_progress) self.worker.progress.connect(self.update_progress)
@@ -1703,16 +1958,16 @@ class SpotiFLACGUI(QWidget):
self.update_ui_for_download_start() self.update_ui_for_download_start()
def update_ui_for_download_start(self): def update_ui_for_download_start(self):
self.download_selected_btn.setEnabled(False) self.download_btn.setEnabled(False)
self.download_all_btn.setEnabled(False)
if hasattr(self, 'single_download_btn'): if hasattr(self, 'single_download_btn'):
self.single_download_btn.setEnabled(False) self.single_download_btn.setEnabled(False)
if hasattr(self, 'single_clear_btn'): if hasattr(self, 'single_delete_btn'):
self.single_clear_btn.setEnabled(False) self.single_delete_btn.setEnabled(False)
self.stop_btn.show() self.stop_btn.show()
self.pause_resume_btn.show() self.pause_resume_btn.show()
self.remove_successful_btn.hide()
self.progress_bar.show() self.progress_bar.show()
self.progress_bar.setValue(0) self.progress_bar.setValue(0)
@@ -1747,13 +2002,12 @@ class SpotiFLACGUI(QWidget):
else: else:
self.remove_successful_btn.hide() self.remove_successful_btn.hide()
self.download_selected_btn.setEnabled(True) self.download_btn.setEnabled(True)
self.download_all_btn.setEnabled(True)
if hasattr(self, 'single_download_btn'): if hasattr(self, 'single_download_btn'):
self.single_download_btn.setEnabled(True) self.single_download_btn.setEnabled(True)
if hasattr(self, 'single_clear_btn'): if hasattr(self, 'single_delete_btn'):
self.single_clear_btn.setEnabled(True) self.single_delete_btn.setEnabled(True)
if success: if success:
self.log_output.append(f"\nStatus: {message}") self.log_output.append(f"\nStatus: {message}")
@@ -1830,11 +2084,27 @@ class SpotiFLACGUI(QWidget):
self.remove_successful_btn.hide() self.remove_successful_btn.hide()
def remove_selected_tracks(self): def delete_tracks(self):
if not self.is_single_track: if self.is_single_track:
self.reset_state()
self.reset_ui()
else:
selected_items = self.track_list.selectedItems() selected_items = self.track_list.selectedItems()
selected_indices = [self.track_list.row(item) for item in selected_items]
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] tracks_to_remove = [self.tracks[i] for i in selected_indices]
for track in tracks_to_remove: for track in tracks_to_remove:
@@ -1843,13 +2113,7 @@ class SpotiFLACGUI(QWidget):
if track in self.all_tracks: if track in self.all_tracks:
self.all_tracks.remove(track) self.all_tracks.remove(track)
self.update_track_list_display() self.update_track_list_display()
def clear_tracks(self):
self.reset_state()
self.reset_ui()
self.tab_widget.setCurrentIndex(0) self.tab_widget.setCurrentIndex(0)
def start_timer(self): def start_timer(self):
+17 -3
View File
@@ -1,5 +1,6 @@
from time import sleep from time import sleep
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
from pathlib import Path
import requests import requests
import json import json
import time import time
@@ -14,19 +15,32 @@ def get_random_user_agent():
# https://github.com/xyloflake/spot-secrets-go # https://github.com/xyloflake/spot-secrets-go
def generate_totp(): 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: try:
url = "https://raw.githubusercontent.com/afkarxyz/secretBytes/refs/heads/main/secrets/secretBytes.json"
resp = requests.get(url, timeout=10) resp = requests.get(url, timeout=10)
if resp.status_code != 200: 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() 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"]) latest_entry = max(secrets_list, key=lambda x: x["version"])
version = latest_entry["version"] version = latest_entry["version"]
secret_cipher = latest_entry["secret"] secret_cipher = latest_entry["secret"]
except Exception as e: 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 = [byte ^ ((i % 33) + 9) for i, byte in enumerate(secret_cipher)]
processed_str = "".join(map(str, processed)) processed_str = "".join(map(str, processed))
+95
View File
@@ -0,0 +1,95 @@
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):
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):
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())
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>

After

Width:  |  Height:  |  Size: 304 B

View File

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>

After

Width:  |  Height:  |  Size: 326 B

View File

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 169 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>

After

Width:  |  Height:  |  Size: 356 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>

After

Width:  |  Height:  |  Size: 402 B

+1
View File
@@ -6,3 +6,4 @@ mutagen
pyotp pyotp
packaging packaging
pyinstaller pyinstaller
DrissionPage
+118 -10
View File
@@ -1,9 +1,9 @@
import asyncio
import json
import os import os
import re import re
import time import time
import base64
import requests import requests
import json
from mutagen.flac import FLAC, Picture from mutagen.flac import FLAC, Picture
from mutagen.id3 import PictureType from mutagen.id3 import PictureType
@@ -16,19 +16,88 @@ class ProgressCallback:
print(f"\r{current / (1024 * 1024):.2f} MB", end="") print(f"\r{current / (1024 * 1024):.2f} MB", end="")
class TidalDownloader: class TidalDownloader:
def __init__(self, timeout=30, max_retries=3): def __init__(self, timeout=30, max_retries=3, api_url=None):
self.timeout = timeout self.timeout = timeout
self.max_retries = max_retries self.max_retries = max_retries
self.download_chunk_size = 256 * 1024 self.download_chunk_size = 256 * 1024
self.progress_callback = ProgressCallback() self.progress_callback = ProgressCallback()
self.client_id = "zU4XHVVkc2tDPo4t" self.client_id = base64.b64decode("NkJEU1JkcEs5aHFFQlRnVQ==").decode()
self.client_secret = "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4=" self.client_secret = base64.b64decode("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=").decode()
self.api_url = api_url or "https://hifi.401658.xyz"
@staticmethod
def get_available_apis():
try:
response = requests.get("https://status.monochrome.tf/api/stream", timeout=10, stream=True)
for line in response.iter_lines():
if line:
line_str = line.decode('utf-8')
if line_str.startswith('data: '):
data = json.loads(line_str[6:])
api_instances = [
inst for inst in data.get('instances', [])
if inst.get('instance_type') == 'api' and inst.get('last_check', {}).get('success')
]
api_instances.sort(key=lambda x: x.get('avg_response_time', 9999))
return api_instances
except Exception as e:
print(f"Failed to fetch API list: {e}")
return []
@staticmethod
def select_api_interactive():
apis = TidalDownloader.get_available_apis()
if not apis:
print("No APIs available, using default: https://hifi.401658.xyz")
return "https://hifi.401658.xyz"
print("\n=== Available API Instances ===")
print(f"{'No':<4} {'URL':<40} {'Status':<8} {'Uptime':<8} {'Avg Response':<12}")
print("-" * 80)
for i, api in enumerate(apis, 1):
url = api.get('url', 'N/A')
status = "UP" if api.get('last_check', {}).get('success') else "DOWN"
uptime = f"{api.get('uptime', 0):.1f}%"
avg_time = f"{api.get('avg_response_time', 0)}ms"
print(f"{i:<4} {url:<40} {status:<8} {uptime:<8} {avg_time:<12}")
print("\n0 Use default (https://hifi.401658.xyz)")
print("-" * 80)
while True:
try:
choice = input(f"\nSelect API (0-{len(apis)}) [1 for fastest]: ").strip()
if not choice:
choice = "1"
choice_num = int(choice)
if choice_num == 0:
return "https://hifi.401658.xyz"
elif 1 <= choice_num <= len(apis):
selected_url = apis[choice_num - 1]['url']
print(f"\nSelected: {selected_url}")
return selected_url
else:
print(f"Invalid choice. Please enter 0-{len(apis)}")
except ValueError:
print("Invalid input. Please enter a number.")
except KeyboardInterrupt:
print("\nUsing default API")
return "https://hifi.401658.xyz"
def set_progress_callback(self, callback): def set_progress_callback(self, callback):
self.progress_callback = callback self.progress_callback = callback
def sanitize_filename(self, filename): def sanitize_filename(self, filename):
if not filename: if not filename:
return "Unknown Track" return "Unknown Track"
@@ -144,7 +213,7 @@ class TidalDownloader:
def get_download_url(self, track_id, quality="LOSSLESS"): def get_download_url(self, track_id, quality="LOSSLESS"):
print("Fetching URL...") print("Fetching URL...")
download_api_url = f"https://tidal.401658.xyz/track/?id={track_id}&quality={quality}" download_api_url = f"{self.api_url}/track/?id={track_id}&quality={quality}"
try: try:
response = requests.get(download_api_url, timeout=self.timeout) response = requests.get(download_api_url, timeout=self.timeout)
@@ -184,6 +253,10 @@ class TidalDownloader:
return None return None
def download_file(self, url, filepath, is_paused_callback=None, is_stopped_callback=None): def download_file(self, url, filepath, is_paused_callback=None, is_stopped_callback=None):
file_dir = os.path.dirname(filepath)
if file_dir and not os.path.exists(file_dir):
os.makedirs(file_dir, exist_ok=True)
temp_filepath = filepath + ".part" temp_filepath = filepath + ".part"
retry_count = 0 retry_count = 0
@@ -316,13 +389,46 @@ class TidalDownloader:
print(f"Error embedding metadata: {str(e)}") print(f"Error embedding metadata: {str(e)}")
return False return False
def download(self, query, isrc=None, output_dir=".", quality="LOSSLESS", is_paused_callback=None, is_stopped_callback=None): def download(self, query, isrc=None, output_dir=".", quality="LOSSLESS", is_paused_callback=None, is_stopped_callback=None, auto_fallback=False):
if output_dir != ".": if output_dir != ".":
try: try:
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
except OSError as e: except OSError as e:
raise Exception(f"Directory error: {e}") raise Exception(f"Directory error: {e}")
if auto_fallback:
apis = self.get_available_apis()
if not apis:
print("No APIs available for fallback, using current API")
return self._download_single(query, isrc, output_dir, quality, is_paused_callback, is_stopped_callback)
last_error = None
for i, api in enumerate(apis, 1):
api_url = api.get('url')
try:
print(f"[Auto Fallback {i}/{len(apis)}] Trying: {api_url}")
fallback_downloader = TidalDownloader(api_url=api_url)
fallback_downloader.set_progress_callback(self.progress_callback)
result = fallback_downloader._download_single(
query, isrc, output_dir, quality,
is_paused_callback, is_stopped_callback
)
print(f"✓ Success with: {api_url}")
return result
except Exception as e:
last_error = str(e)
print(f"✗ Failed with {api_url}: {last_error[:80]}")
continue
raise Exception(f"All {len(apis)} APIs failed. Last error: {last_error}")
return self._download_single(query, isrc, output_dir, quality, is_paused_callback, is_stopped_callback)
def _download_single(self, query, isrc, output_dir, quality, is_paused_callback, is_stopped_callback):
track_info = self.get_track_info(query, isrc) track_info = self.get_track_info(query, isrc)
track_id = track_info.get("id") track_id = track_info.get("id")
@@ -373,7 +479,9 @@ class TidalDownloader:
def main(): def main():
print("=== TidalDL - Tidal Downloader ===") print("=== TidalDL - Tidal Downloader ===")
downloader = TidalDownloader(timeout=30, max_retries=3)
selected_api = TidalDownloader.select_api_interactive()
downloader = TidalDownloader(timeout=30, max_retries=3, api_url=selected_api)
query = "APT." query = "APT."
isrc = "USAT22409172" isrc = "USAT22409172"
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"version": "4.7" "version": "5.2"
} }