Compare commits

..

16 Commits

Author SHA1 Message Date
afkarxyz 711c5a98d3 v5.4 2025-11-17 12:48:04 +07:00
afkarxyz fd949a17f0 Add tidal.json with a list of sites 2025-11-17 11:49:38 +07:00
afkarxyz 1c0de8b3ac Merge pull request #69 from MaitreGEEK/patch-1
Update download link to latest release
2025-10-22 13:48:50 +07:00
MaîtreGEEK b653a8ca41 Update download link to latest release
In that way when you release a new version you don't have to modify the README
2025-10-22 08:46:20 +02:00
afkarxyz 2d0c174c50 v5.3 2025-10-22 05:17:57 +07:00
afkarxyz c63eeccc55 v5.3 2025-10-22 05:17:22 +07:00
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
5 changed files with 446 additions and 91 deletions
+2 -2
View File
@@ -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/latest/download/SpotiFLAC.exe)
## Screenshots
@@ -16,7 +16,7 @@
![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/169da4f1-7b8a-4d50-b72e-c2fe3c51976e)
## Lossless Audio Check
+321 -77
View File
@@ -6,6 +6,7 @@ from pathlib import Path
import requests
import re
import asyncio
import json
from packaging import version
import qdarktheme
from mutagen.flac import FLAC
@@ -83,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
@@ -96,6 +97,7 @@ 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 = []
@@ -123,11 +125,14 @@ class DownloadWorker(QThread):
def run(self):
try:
if self.service == "tidal":
downloader = TidalDownloader()
downloader = TidalDownloader(api_url=self.tidal_api_url)
deezer_downloader = DeezerDownloader()
elif self.service == "deezer":
downloader = DeezerDownloader()
deezer_downloader = None
else:
downloader = TidalDownloader()
downloader = TidalDownloader(api_url=self.tidal_api_url)
deezer_downloader = DeezerDownloader()
def progress_update(current, total):
if total <= 0:
@@ -155,10 +160,12 @@ class DownloadWorker(QThread):
if self.use_artist_subfolders:
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 = artist_folder.rstrip('. ')
track_outpath = os.path.join(track_outpath, artist_folder)
if self.use_album_subfolders:
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)
os.makedirs(track_outpath, exist_ok=True)
@@ -211,13 +218,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):
@@ -309,6 +319,46 @@ class DownloadWorker(QThread):
int((i + 1) / total_tracks * 100))
self.successful_tracks.append(track)
except Exception as e:
if self.service == "tidal" and deezer_downloader and track.isrc:
try:
self.progress.emit(f"Tidal failed, trying Deezer fallback for: {track.title}", 0)
success = asyncio.run(deezer_downloader.download_by_isrc(track.isrc, track_outpath))
if success:
safe_title = "".join(c for c in track.title if c.isalnum() or c in (' ', '-', '_')).rstrip()
safe_artist = "".join(c for c in track.artists if c.isalnum() or c in (' ', '-', '_')).rstrip()
expected_filename = f"{safe_artist} - {safe_title}.flac"
downloaded_file = os.path.join(track_outpath, expected_filename)
if not os.path.exists(downloaded_file):
import glob
flac_files = glob.glob(os.path.join(track_outpath, "*.flac"))
if flac_files:
downloaded_file = max(flac_files, key=os.path.getctime)
else:
raise Exception("Downloaded file not found")
if downloaded_file != new_filepath:
try:
os.rename(downloaded_file, new_filepath)
self.progress.emit(f"File renamed to: {new_filename}", 0)
except OSError:
pass
self.progress.emit(f"Successfully downloaded via Deezer fallback: {track.title} - {track.artists}",
int((i + 1) / total_tracks * 100))
self.successful_tracks.append(track)
continue
else:
raise Exception("Deezer fallback also failed")
except Exception as deezer_error:
self.progress.emit(f"Deezer fallback also failed: {str(deezer_error)}", 0)
self.failed_tracks.append((track.title, track.artists, f"Tidal: {str(e)}, Deezer: {str(deezer_error)}"))
self.progress.emit(f"Failed to download: {track.title} - {track.artists}\nBoth services failed",
int((i + 1) / total_tracks * 100))
continue
self.failed_tracks.append((track.title, track.artists, str(e)))
self.progress.emit(f"Failed to download: {track.title} - {track.artists}\nError: {str(e)}",
int((i + 1) / total_tracks * 100))
@@ -368,33 +418,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
@@ -413,32 +437,142 @@ 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 check_single_api(self, api):
try:
url = api.get('url', '')
test_response = requests.get(f"{url}/track/?id=251380837&quality=LOSSLESS", timeout=5)
return test_response.status_code == 200
except:
return False
def run(self):
try:
from concurrent.futures import ThreadPoolExecutor, as_completed
apis = TidalDownloader.get_available_apis()
if not apis:
self.status_updated.emit(False)
return
any_online = False
with ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(self.check_single_api, api): api for api in apis}
for future in as_completed(futures):
try:
if future.result():
any_online = True
for f in futures:
f.cancel()
break
except:
continue
self.status_updated.emit(any_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 APIStatusChecker(QThread):
status_checked = pyqtSignal(str, str)
all_completed = pyqtSignal()
def __init__(self, apis):
super().__init__()
self.apis = apis
def check_single_api(self, api):
url = api.get('url', '')
try:
test_response = requests.get(f"{url}/track/?id=251380837&quality=LOSSLESS", timeout=5)
is_online = test_response.status_code == 200
status = 'UP' if is_online else 'DOWN'
except:
status = 'DOWN'
return (url, status)
def run(self):
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(self.check_single_api, api): api for api in self.apis}
for future in as_completed(futures):
try:
url, status = future.result()
self.status_checked.emit(url, status)
except Exception as e:
print(f"Error checking API: {e}")
self.all_completed.emit()
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.start()
QTimer.singleShot(100, self.start_tidal_status_check)
QTimer.singleShot(100, self.start_deezer_status_check)
self.tidal_status_timer = QTimer(self)
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.start()
self.tidal_status_timer.timeout.connect(self.refresh_tidal_status)
self.tidal_status_timer.start(60000)
self.deezer_status_timer = QTimer(self)
self.deezer_status_timer.timeout.connect(self.refresh_deezer_status)
self.deezer_status_timer.start(60000)
self.deezer_status_timer.timeout.connect(self.refresh_deezer_status)
self.deezer_status_timer.start(60000)
def start_tidal_status_check(self):
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 start_deezer_status_check(self):
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 setup_items(self):
current_dir = os.path.dirname(os.path.abspath(__file__))
@@ -463,42 +597,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):
@@ -507,7 +646,7 @@ class ServiceComboBox(QComboBox):
class SpotiFLACGUI(QWidget):
def __init__(self):
super().__init__()
self.current_version = "5.0"
self.current_version = "5.4"
self.tracks = []
self.all_tracks = []
self.successful_downloads = []
@@ -522,6 +661,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', 'auto')
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')
@@ -1109,17 +1249,46 @@ 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(53)
self.service_dropdown = ServiceComboBox()
self.service_dropdown.setFixedWidth(100)
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)
self.api_spacer = QWidget()
self.api_spacer.setFixedWidth(15)
service_api_layout.addWidget(self.api_spacer)
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("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, 2)
service_api_layout.addSpacing(5)
service_api_layout.addWidget(self.refresh_api_btn)
service_api_layout.addStretch(1)
auth_layout.addLayout(service_api_layout)
self.refresh_tidal_apis()
self.update_tidal_api_visibility()
settings_layout.addWidget(auth_group)
settings_layout.addStretch()
@@ -1129,6 +1298,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()
@@ -1349,7 +1520,7 @@ class SpotiFLACGUI(QWidget):
about_layout.addWidget(section_widget)
footer_label = QLabel(f"v{self.current_version} | October 2025")
footer_label = QLabel(f"v{self.current_version} | November 2025")
about_layout.addWidget(footer_label, alignment=Qt.AlignmentFlag.AlignCenter)
about_tab.setLayout(about_layout)
@@ -1361,6 +1532,66 @@ 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.api_spacer.setVisible(is_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() > 1:
self.tidal_api_dropdown.removeItem(1)
if apis:
self.log_output.append(f"Found {len(apis)} API instances, loading...")
for api in apis:
url = api.get('url', '')
domain = url.replace('https://', '').replace('http://', '')
label = domain
status_data = {'status': 'CHECKING'}
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"Loaded {len(apis)} API instances, checking status in background...")
self.api_status_checker = APIStatusChecker(apis)
self.api_status_checker.status_checked.connect(self.on_api_status_checked)
self.api_status_checker.all_completed.connect(self.on_all_api_status_completed)
self.api_status_checker.start()
else:
self.log_output.append("No APIs found")
except Exception as e:
self.log_output.append(f"Error fetching APIs: {str(e)}")
def on_api_status_checked(self, url, status):
for i in range(self.tidal_api_dropdown.count()):
if self.tidal_api_dropdown.itemData(i) == url:
status_data = {'status': status}
self.tidal_api_dropdown.setItemData(i, status_data, Qt.ItemDataRole.UserRole + 1)
break
self.tidal_api_dropdown.update()
def on_all_api_status_completed(self):
self.log_output.append("API status check completed")
def save_url(self):
self.settings.setValue('spotify_url', self.spotify_url.text().strip())
@@ -1807,6 +2038,7 @@ class SpotiFLACGUI(QWidget):
if self.is_album or self.is_playlist:
name = self.album_or_playlist_name.strip()
folder_name = re.sub(r'[<>:"/\\|?*]', '_', name)
folder_name = folder_name.rstrip('. ')
outpath = os.path.join(outpath, folder_name)
os.makedirs(outpath, exist_ok=True)
@@ -1818,6 +2050,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,
@@ -1829,7 +2071,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)
@@ -1847,6 +2090,7 @@ class SpotiFLACGUI(QWidget):
self.stop_btn.show()
self.pause_resume_btn.show()
self.remove_successful_btn.hide()
self.progress_bar.show()
self.progress_bar.setValue(0)
+17
View File
@@ -0,0 +1,17 @@
[
"vogel.qqdl.site",
"maus.qqdl.site",
"hund.qqdl.site",
"eu-maus.qqdl.site",
"eu-katze.qqdl.site",
"katze.qqdl.site",
"wolf.qqdl.site",
"zeus.squid.wtf",
"shiva.squid.wtf",
"kraken.squid.wtf",
"dev-api.squid.wtf",
"chaos.squid.wtf",
"phoenix.squid.wtf",
"triton.squid.wtf",
"aether.squid.wtf"
]
+105 -11
View File
@@ -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,75 @@ 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
@staticmethod
def get_available_apis():
try:
response = requests.get("https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/tidal.json", timeout=10)
if response.status_code == 200:
api_list = response.json()
api_instances = [{"url": f"https://{api}"} for api in api_list]
return api_instances
else:
print(f"Failed to fetch API list: HTTP {response.status_code}")
return []
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:
raise Exception("No APIs available. Cannot proceed.")
print("\n=== Available API Instances ===")
print(f"{'No':<4} {'URL':<50}")
print("-" * 60)
for i, api in enumerate(apis, 1):
url = api.get('url', 'N/A')
print(f"{i:<4} {url:<50}")
print("-" * 60)
while True:
try:
choice = input(f"\nSelect API (1-{len(apis)}) [1]: ").strip()
if not choice:
choice = "1"
choice_num = int(choice)
if 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 1-{len(apis)}")
except ValueError:
print("Invalid input. Please enter a number.")
except KeyboardInterrupt:
print("\nCancelled")
raise Exception("API selection cancelled")
def set_progress_callback(self, callback):
self.progress_callback = callback
def sanitize_filename(self, filename):
if not filename:
return "Unknown Track"
@@ -144,7 +200,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)
@@ -184,6 +240,10 @@ class TidalDownloader:
return 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"
retry_count = 0
@@ -316,13 +376,45 @@ 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:
raise Exception("No APIs available for fallback")
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 +465,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
View File
@@ -1,3 +1,3 @@
{
"version": "4.9"
"version": "5.3"
}