Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25c5a4d175 | |||
| 33a6137f75 | |||
| b4fcb6bca6 | |||
| 5ab19a6d37 | |||
| 8547e6d410 | |||
| 17666d8027 | |||
| ab208482ca | |||
| 76e02d77e8 | |||
| 75cc4543ad | |||
| 0b468c4b60 | |||
| 87a6a778f7 | |||
| ef893ab9f4 |
@@ -6,7 +6,7 @@
|
||||
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Tidal & Deezer.
|
||||
</div>
|
||||
|
||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.9/SpotiFLAC.exe)
|
||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v5.1/SpotiFLAC.exe)
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## Lossless Audio Check
|
||||
|
||||
|
||||
+219
-70
@@ -6,8 +6,10 @@ from pathlib import Path
|
||||
import requests
|
||||
import re
|
||||
import asyncio
|
||||
import json
|
||||
from packaging import version
|
||||
import qdarktheme
|
||||
from mutagen.flac import FLAC
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit,
|
||||
@@ -82,7 +84,7 @@ class DownloadWorker(QThread):
|
||||
|
||||
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,
|
||||
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__()
|
||||
self.tracks = tracks
|
||||
self.outpath = outpath
|
||||
@@ -95,12 +97,22 @@ class DownloadWorker(QThread):
|
||||
self.use_artist_subfolders = use_artist_subfolders
|
||||
self.use_album_subfolders = use_album_subfolders
|
||||
self.service = service
|
||||
self.tidal_api_url = tidal_api_url
|
||||
self.is_paused = False
|
||||
self.is_stopped = False
|
||||
self.failed_tracks = []
|
||||
self.successful_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):
|
||||
if self.filename_format == "artist_title":
|
||||
filename = f"{track.artists} - {track.title}.flac"
|
||||
@@ -113,11 +125,11 @@ class DownloadWorker(QThread):
|
||||
def run(self):
|
||||
try:
|
||||
if self.service == "tidal":
|
||||
downloader = TidalDownloader()
|
||||
downloader = TidalDownloader(api_url=self.tidal_api_url)
|
||||
elif self.service == "deezer":
|
||||
downloader = DeezerDownloader()
|
||||
else:
|
||||
downloader = TidalDownloader()
|
||||
downloader = TidalDownloader(api_url=self.tidal_api_url)
|
||||
|
||||
def progress_update(current, total):
|
||||
if total <= 0:
|
||||
@@ -155,6 +167,27 @@ class DownloadWorker(QThread):
|
||||
else:
|
||||
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:
|
||||
new_filename = f"{track.track_number:02d} - {self.get_formatted_filename(track)}"
|
||||
else:
|
||||
@@ -164,7 +197,7 @@ class DownloadWorker(QThread):
|
||||
new_filepath = os.path.join(track_outpath, new_filename)
|
||||
|
||||
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}",
|
||||
int((i + 1) / total_tracks * 100))
|
||||
self.skipped_tracks.append(track)
|
||||
@@ -180,13 +213,16 @@ class DownloadWorker(QThread):
|
||||
is_paused_callback = lambda: self.is_paused
|
||||
is_stopped_callback = lambda: self.is_stopped
|
||||
|
||||
auto_fallback = (self.tidal_api_url == "auto")
|
||||
|
||||
download_result_details = downloader.download(
|
||||
query=f"{track.title} {track.artists}",
|
||||
isrc=track.isrc,
|
||||
output_dir=track_outpath,
|
||||
quality="LOSSLESS",
|
||||
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):
|
||||
@@ -337,33 +373,7 @@ class UpdateDialog(QDialog):
|
||||
self.update_button.clicked.connect(self.accept)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
|
||||
class TidalStatusChecker(QThread):
|
||||
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):
|
||||
class ServiceStatusDelegate(QStyledItemDelegate):
|
||||
def paint(self, painter, option, index):
|
||||
item_data = index.data(Qt.ItemDataRole.UserRole)
|
||||
is_online = item_data.get('online', False) if item_data else False
|
||||
@@ -382,31 +392,75 @@ class StatusIndicatorDelegate(QStyledItemDelegate):
|
||||
painter.drawEllipse(circle_x, circle_y, circle_size, circle_size)
|
||||
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):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setIconSize(QSize(16, 16))
|
||||
self.services_status = {}
|
||||
|
||||
self.setItemDelegate(StatusIndicatorDelegate())
|
||||
self.setItemDelegate(ServiceStatusDelegate())
|
||||
self.setup_items()
|
||||
|
||||
self.tidal_status_checker = TidalStatusChecker()
|
||||
self.tidal_status_checker.status_updated.connect(self.update_tidal_service_status)
|
||||
self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}"))
|
||||
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.start()
|
||||
|
||||
self.tidal_status_timer = QTimer(self)
|
||||
self.tidal_status_timer.timeout.connect(self.refresh_tidal_status)
|
||||
self.tidal_status_timer.start(60000)
|
||||
self.tidal_status_timer.timeout.connect(self.refresh_tidal_status)
|
||||
self.tidal_status_timer.start(60000)
|
||||
|
||||
self.deezer_status_checker = DeezerStatusChecker()
|
||||
self.deezer_status_checker.status_updated.connect(self.update_deezer_service_status)
|
||||
self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}"))
|
||||
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.start()
|
||||
|
||||
self.deezer_status_timer = QTimer(self)
|
||||
self.deezer_status_timer.timeout.connect(self.refresh_deezer_status)
|
||||
self.deezer_status_timer.timeout.connect(self.refresh_deezer_status)
|
||||
self.deezer_status_timer.start(60000)
|
||||
|
||||
def setup_items(self):
|
||||
@@ -432,42 +486,47 @@ class ServiceComboBox(QComboBox):
|
||||
pixmap = QPixmap(16, 16)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
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()):
|
||||
current_service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
||||
if current_service_id == service_id:
|
||||
service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
||||
if service_id == 'tidal':
|
||||
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
|
||||
break
|
||||
self.update()
|
||||
|
||||
def update_tidal_service_status(self, is_online):
|
||||
self.update_service_status('tidal', is_online)
|
||||
|
||||
|
||||
def refresh_tidal_status(self):
|
||||
if hasattr(self, 'tidal_status_checker') and self.tidal_status_checker.isRunning():
|
||||
self.tidal_status_checker.quit()
|
||||
self.tidal_status_checker.wait()
|
||||
|
||||
self.tidal_status_checker = TidalStatusChecker()
|
||||
self.tidal_status_checker.status_updated.connect(self.update_tidal_service_status)
|
||||
self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}"))
|
||||
|
||||
self.tidal_status_checker = TidalStatusChecker()
|
||||
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.start()
|
||||
|
||||
def update_deezer_service_status(self, is_online):
|
||||
self.update_service_status('deezer', is_online)
|
||||
|
||||
|
||||
def update_deezer_status(self, 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):
|
||||
if hasattr(self, 'deezer_status_checker') and self.deezer_status_checker.isRunning():
|
||||
self.deezer_status_checker.quit()
|
||||
self.deezer_status_checker.wait()
|
||||
|
||||
self.deezer_status_checker = DeezerStatusChecker()
|
||||
self.deezer_status_checker.status_updated.connect(self.update_deezer_service_status)
|
||||
self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}"))
|
||||
|
||||
self.deezer_status_checker = DeezerStatusChecker()
|
||||
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.start()
|
||||
|
||||
def currentData(self, role=Qt.ItemDataRole.UserRole + 1):
|
||||
@@ -476,7 +535,7 @@ class ServiceComboBox(QComboBox):
|
||||
class SpotiFLACGUI(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.current_version = "4.9"
|
||||
self.current_version = "5.2"
|
||||
self.tracks = []
|
||||
self.all_tracks = []
|
||||
self.successful_downloads = []
|
||||
@@ -491,6 +550,7 @@ class SpotiFLACGUI(QWidget):
|
||||
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.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.current_theme_color = self.settings.value('theme_color', '#2196F3')
|
||||
self.track_list_format = self.settings.value('track_list_format', 'track_artist_date_duration')
|
||||
@@ -1078,17 +1138,44 @@ class SpotiFLACGUI(QWidget):
|
||||
auth_label.setStyleSheet("font-weight: bold; margin-top: 8px; margin-bottom: 5px;")
|
||||
auth_layout.addWidget(auth_label)
|
||||
|
||||
service_fallback_layout = QHBoxLayout()
|
||||
service_api_layout = QHBoxLayout()
|
||||
|
||||
service_label = QLabel('Service:')
|
||||
service_label.setFixedWidth(60)
|
||||
|
||||
self.service_dropdown = ServiceComboBox()
|
||||
self.service_dropdown.setFixedWidth(120)
|
||||
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()
|
||||
auth_layout.addLayout(service_fallback_layout)
|
||||
service_api_layout.addWidget(service_label)
|
||||
service_api_layout.addWidget(self.service_dropdown)
|
||||
service_api_layout.addSpacing(15)
|
||||
|
||||
self.tidal_api_label = QLabel('Tidal API:')
|
||||
self.tidal_api_label.setFixedWidth(70)
|
||||
|
||||
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)
|
||||
service_api_layout.addSpacing(5)
|
||||
service_api_layout.addWidget(self.refresh_api_btn)
|
||||
service_api_layout.addStretch()
|
||||
|
||||
auth_layout.addLayout(service_api_layout)
|
||||
|
||||
self.refresh_tidal_apis()
|
||||
|
||||
self.update_tidal_api_visibility()
|
||||
|
||||
settings_layout.addWidget(auth_group)
|
||||
settings_layout.addStretch()
|
||||
@@ -1098,6 +1185,8 @@ class SpotiFLACGUI(QWidget):
|
||||
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.tidal_api_dropdown, self.tidal_api)
|
||||
|
||||
def setup_theme_tab(self):
|
||||
theme_tab = QWidget()
|
||||
theme_layout = QVBoxLayout()
|
||||
@@ -1330,6 +1419,55 @@ class SpotiFLACGUI(QWidget):
|
||||
self.settings.setValue('service', service)
|
||||
self.settings.sync()
|
||||
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"Tidal API changed to: {self.tidal_api_dropdown.currentText()}")
|
||||
|
||||
def refresh_tidal_apis(self):
|
||||
try:
|
||||
self.log_output.append("Fetching available Tidal APIs...")
|
||||
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 Tidal APIs")
|
||||
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):
|
||||
self.settings.setValue('spotify_url', self.spotify_url.text().strip())
|
||||
@@ -1787,6 +1925,16 @@ class SpotiFLACGUI(QWidget):
|
||||
def start_download_worker(self, tracks_to_download, outpath):
|
||||
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(
|
||||
tracks_to_download,
|
||||
outpath,
|
||||
@@ -1798,7 +1946,8 @@ class SpotiFLACGUI(QWidget):
|
||||
self.use_track_numbers,
|
||||
self.use_artist_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.progress.connect(self.update_progress)
|
||||
|
||||
@@ -31,15 +31,7 @@ def summarise(caps):
|
||||
|
||||
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)
|
||||
@@ -87,20 +79,12 @@ def grab_live(progress_callback=None):
|
||||
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)
|
||||
|
||||
+115
-11
@@ -1,9 +1,9 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import base64
|
||||
import requests
|
||||
import json
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.id3 import PictureType
|
||||
|
||||
@@ -16,19 +16,88 @@ class ProgressCallback:
|
||||
print(f"\r{current / (1024 * 1024):.2f} MB", end="")
|
||||
|
||||
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.max_retries = max_retries
|
||||
self.download_chunk_size = 256 * 1024
|
||||
self.progress_callback = ProgressCallback()
|
||||
self.client_id = "zU4XHVVkc2tDPo4t"
|
||||
self.client_secret = "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4="
|
||||
self.client_id = base64.b64decode("NkJEU1JkcEs5aHFFQlRnVQ==").decode()
|
||||
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 Tidal APIs ===")
|
||||
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):
|
||||
self.progress_callback = callback
|
||||
|
||||
|
||||
|
||||
def sanitize_filename(self, filename):
|
||||
if not filename:
|
||||
return "Unknown Track"
|
||||
@@ -144,7 +213,7 @@ class TidalDownloader:
|
||||
|
||||
def get_download_url(self, track_id, quality="LOSSLESS"):
|
||||
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:
|
||||
response = requests.get(download_api_url, timeout=self.timeout)
|
||||
@@ -316,13 +385,46 @@ class TidalDownloader:
|
||||
print(f"Error embedding metadata: {str(e)}")
|
||||
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 != ".":
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
except OSError as 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_id = track_info.get("id")
|
||||
|
||||
@@ -373,7 +475,9 @@ class TidalDownloader:
|
||||
|
||||
def main():
|
||||
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."
|
||||
isrc = "USAT22409172"
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "4.8"
|
||||
"version": "5.1"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user