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. <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.9/SpotiFLAC.exe) ### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/latest/download/SpotiFLAC.exe)
## Screenshots ## Screenshots
@@ -16,7 +16,7 @@
![image](https://github.com/user-attachments/assets/7507e58d-e228-4edf-adf7-675731731019) ![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 ## Lossless Audio Check
+321 -77
View File
@@ -6,6 +6,7 @@ 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 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, 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
@@ -96,6 +97,7 @@ 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 = []
@@ -123,11 +125,14 @@ 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)
deezer_downloader = DeezerDownloader()
elif self.service == "deezer": elif self.service == "deezer":
downloader = DeezerDownloader() downloader = DeezerDownloader()
deezer_downloader = None
else: else:
downloader = TidalDownloader() downloader = TidalDownloader(api_url=self.tidal_api_url)
deezer_downloader = DeezerDownloader()
def progress_update(current, total): def progress_update(current, total):
if total <= 0: if total <= 0:
@@ -155,10 +160,12 @@ 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)
@@ -211,13 +218,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):
@@ -309,6 +319,46 @@ class DownloadWorker(QThread):
int((i + 1) / total_tracks * 100)) int((i + 1) / total_tracks * 100))
self.successful_tracks.append(track) self.successful_tracks.append(track)
except Exception as e: 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.failed_tracks.append((track.title, track.artists, str(e)))
self.progress.emit(f"Failed to download: {track.title} - {track.artists}\nError: {str(e)}", self.progress.emit(f"Failed to download: {track.title} - {track.artists}\nError: {str(e)}",
int((i + 1) / total_tracks * 100)) int((i + 1) / total_tracks * 100))
@@ -368,33 +418,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
@@ -413,32 +437,142 @@ 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 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): 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() QTimer.singleShot(100, self.start_tidal_status_check)
self.tidal_status_checker.status_updated.connect(self.update_tidal_service_status) QTimer.singleShot(100, self.start_deezer_status_check)
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 = QTimer(self)
self.tidal_status_timer.timeout.connect(self.refresh_tidal_status) self.tidal_status_timer.timeout.connect(self.refresh_tidal_status)
self.tidal_status_timer.start(60000) 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.deezer_status_timer = QTimer(self) 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) 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): def setup_items(self):
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
@@ -463,42 +597,47 @@ class ServiceComboBox(QComboBox):
pixmap = QPixmap(16, 16) pixmap = QPixmap(16, 16)
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
self.setItemData(i, service_data, Qt.ItemDataRole.UserRole) self.setItemData(i, service_data, Qt.ItemDataRole.UserRole)
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():
self.deezer_status_checker.quit() self.deezer_status_checker.quit()
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()
def currentData(self, role=Qt.ItemDataRole.UserRole + 1): def currentData(self, role=Qt.ItemDataRole.UserRole + 1):
@@ -507,7 +646,7 @@ class ServiceComboBox(QComboBox):
class SpotiFLACGUI(QWidget): class SpotiFLACGUI(QWidget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.current_version = "5.0" self.current_version = "5.4"
self.tracks = [] self.tracks = []
self.all_tracks = [] self.all_tracks = []
self.successful_downloads = [] 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_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', 'auto')
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')
@@ -1109,17 +1249,46 @@ 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)
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.addWidget(auth_group)
settings_layout.addStretch() 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.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()
@@ -1349,7 +1520,7 @@ class SpotiFLACGUI(QWidget):
about_layout.addWidget(section_widget) 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_layout.addWidget(footer_label, alignment=Qt.AlignmentFlag.AlignCenter)
about_tab.setLayout(about_layout) about_tab.setLayout(about_layout)
@@ -1361,6 +1532,66 @@ 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.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): def save_url(self):
self.settings.setValue('spotify_url', self.spotify_url.text().strip()) 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: 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)
@@ -1818,6 +2050,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,
@@ -1829,7 +2071,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)
@@ -1847,6 +2090,7 @@ class SpotiFLACGUI(QWidget):
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)
+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 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,75 @@ 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
@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): 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 +200,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 +240,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 +376,45 @@ 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:
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_info = self.get_track_info(query, isrc)
track_id = track_info.get("id") track_id = track_info.get("id")
@@ -373,7 +465,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.9" "version": "5.3"
} }