Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a620c16b1c | |||
| cf27ae098d | |||
| a0c60a473a | |||
| 25c5a4d175 | |||
| 33a6137f75 | |||
| b4fcb6bca6 | |||
| 5ab19a6d37 | |||
| 8547e6d410 | |||
| 17666d8027 | |||
| ab208482ca | |||
| 76e02d77e8 | |||
| 75cc4543ad | |||
| 0b468c4b60 | |||
| 87a6a778f7 | |||
| ef893ab9f4 | |||
| 3eda3245ca | |||
| f6f238361c | |||
| 998730bbb3 |
@@ -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
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Lossless Audio Check
|
## Lossless Audio Check
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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 |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
@@ -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 |
@@ -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 |
@@ -6,3 +6,4 @@ mutagen
|
|||||||
pyotp
|
pyotp
|
||||||
packaging
|
packaging
|
||||||
pyinstaller
|
pyinstaller
|
||||||
|
DrissionPage
|
||||||
@@ -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,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "4.7"
|
"version": "5.2"
|
||||||
}
|
}
|
||||||
|
|||||||