Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55669ec45f | |||
| 3304b13828 | |||
| 579bb1415a | |||
| f0e71261a5 | |||
| e2ad51da34 | |||
| 9e403ab1ba | |||
| 7058559ddc | |||
| 861f303a4f | |||
| 9a28e8bd94 | |||
| f75385c4e8 | |||
| 2eac274ee0 | |||
| 49a8de1b35 | |||
| cd2500d1df | |||
| ea1372f1fe | |||
| 65fbb9a8e9 |
@@ -3,10 +3,10 @@
|
||||

|
||||
|
||||
<div align="center">
|
||||
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Qobuz, Tidal & Deezer.
|
||||
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Tidal & Deezer.
|
||||
</div>
|
||||
|
||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.3/SpotiFLAC.exe)
|
||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.6/SpotiFLAC.exe)
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Lossless Audio Check
|
||||
|
||||

|
||||
|
||||
+336
-274
@@ -20,10 +20,8 @@ from PyQt6.QtGui import QIcon, QTextCursor, QDesktopServices, QPixmap, QBrush
|
||||
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
|
||||
|
||||
from getMetadata import get_filtered_data, parse_uri, SpotifyInvalidUrlException
|
||||
from qobuzDL import QobuzDownloader
|
||||
from tidalDL import TidalDownloader
|
||||
from deezerDL import DeezerDownloader
|
||||
from amazonDL import LucidaDownloader
|
||||
|
||||
@dataclass
|
||||
class Track:
|
||||
@@ -35,6 +33,7 @@ class Track:
|
||||
duration_ms: int
|
||||
id: str
|
||||
isrc: str = ""
|
||||
release_date: str = ""
|
||||
|
||||
class MetadataFetchWorker(QThread):
|
||||
finished = pyqtSignal(dict)
|
||||
@@ -57,12 +56,12 @@ class MetadataFetchWorker(QThread):
|
||||
self.error.emit(f'Failed to fetch metadata: {str(e)}')
|
||||
|
||||
class DownloadWorker(QThread):
|
||||
finished = pyqtSignal(bool, str, list)
|
||||
finished = pyqtSignal(bool, str, list, list, list)
|
||||
progress = pyqtSignal(str, int)
|
||||
|
||||
def __init__(self, tracks, outpath, is_single_track=False, is_album=False, is_playlist=False,
|
||||
album_or_playlist_name='', filename_format='title_artist', use_track_numbers=True,
|
||||
use_artist_subfolders=False, use_album_subfolders=False, service="tidal", qobuz_region="us"):
|
||||
use_artist_subfolders=False, use_album_subfolders=False, service="tidal"):
|
||||
super().__init__()
|
||||
self.tracks = tracks
|
||||
self.outpath = outpath
|
||||
@@ -75,10 +74,11 @@ class DownloadWorker(QThread):
|
||||
self.use_artist_subfolders = use_artist_subfolders
|
||||
self.use_album_subfolders = use_album_subfolders
|
||||
self.service = service
|
||||
self.qobuz_region = qobuz_region
|
||||
self.is_paused = False
|
||||
self.is_stopped = False
|
||||
self.failed_tracks = []
|
||||
self.successful_tracks = []
|
||||
self.skipped_tracks = []
|
||||
|
||||
def get_formatted_filename(self, track):
|
||||
if self.filename_format == "artist_title":
|
||||
@@ -91,14 +91,10 @@ class DownloadWorker(QThread):
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
if self.service == "qobuz":
|
||||
downloader = QobuzDownloader(self.qobuz_region)
|
||||
elif self.service == "tidal":
|
||||
if self.service == "tidal":
|
||||
downloader = TidalDownloader()
|
||||
elif self.service == "deezer":
|
||||
downloader = DeezerDownloader()
|
||||
elif self.service == "amazon":
|
||||
downloader = LucidaDownloader()
|
||||
else:
|
||||
downloader = TidalDownloader()
|
||||
|
||||
@@ -150,26 +146,10 @@ class DownloadWorker(QThread):
|
||||
self.progress.emit(f"File already exists: {new_filename}. Skipping download.", 0)
|
||||
self.progress.emit(f"Skipped: {track.title} - {track.artists}",
|
||||
int((i + 1) / total_tracks * 100))
|
||||
self.skipped_tracks.append(track)
|
||||
continue
|
||||
|
||||
if self.service == "qobuz":
|
||||
if not track.isrc:
|
||||
self.progress.emit(f"No ISRC found for track: {track.title}. Skipping.", 0)
|
||||
self.failed_tracks.append((track.title, track.artists, "No ISRC available"))
|
||||
continue
|
||||
|
||||
self.progress.emit(f"Getting track from Qobuz with ISRC: {track.isrc}", 0)
|
||||
|
||||
is_paused_callback = lambda: self.is_paused
|
||||
is_stopped_callback = lambda: self.is_stopped
|
||||
|
||||
downloaded_file = downloader.download(
|
||||
track.isrc,
|
||||
track_outpath,
|
||||
is_paused_callback=is_paused_callback,
|
||||
is_stopped_callback=is_stopped_callback
|
||||
)
|
||||
elif self.service == "tidal":
|
||||
if self.service == "tidal":
|
||||
if not track.isrc:
|
||||
self.progress.emit(f"No ISRC found for track: {track.title}. Skipping.", 0)
|
||||
self.failed_tracks.append((track.title, track.artists, "No ISRC available"))
|
||||
@@ -198,6 +178,7 @@ class DownloadWorker(QThread):
|
||||
elif isinstance(download_result_details, dict) and (download_result_details.get("status") == "all_skipped" or download_result_details.get("status") == "skipped_exists"):
|
||||
self.progress.emit(f"File already exists or skipped: {new_filename}",0)
|
||||
downloaded_file = new_filepath
|
||||
self.skipped_tracks.append(track)
|
||||
else:
|
||||
downloaded_file = None
|
||||
raise Exception(f"Tidal download failed or returned unexpected result: {download_result_details}")
|
||||
@@ -226,21 +207,6 @@ class DownloadWorker(QThread):
|
||||
raise Exception("Downloaded file not found")
|
||||
else:
|
||||
raise Exception("Deezer download failed")
|
||||
elif self.service == "amazon":
|
||||
self.progress.emit(f"Downloading from Amazon Music: {track.title} - {track.artists}", 0)
|
||||
|
||||
is_paused_callback = lambda: self.is_paused
|
||||
is_stopped_callback = lambda: self.is_stopped
|
||||
|
||||
downloaded_file = downloader.download(
|
||||
track.id,
|
||||
track_outpath,
|
||||
is_paused_callback=is_paused_callback,
|
||||
is_stopped_callback=is_stopped_callback
|
||||
)
|
||||
|
||||
if not downloaded_file or not os.path.exists(downloaded_file):
|
||||
raise Exception("Amazon Music download failed")
|
||||
else:
|
||||
track_id = track.id
|
||||
self.progress.emit(f"Getting track info for ID: {track_id} from {self.service}", 0)
|
||||
@@ -274,6 +240,7 @@ class DownloadWorker(QThread):
|
||||
self.progress.emit(f"File already exists: {new_filename}", 0)
|
||||
self.progress.emit(f"Skipped: {track.title} - {track.artists}",
|
||||
int((i + 1) / total_tracks * 100))
|
||||
self.skipped_tracks.append(track)
|
||||
continue
|
||||
|
||||
if downloaded_file != new_filepath:
|
||||
@@ -288,6 +255,7 @@ class DownloadWorker(QThread):
|
||||
|
||||
self.progress.emit(f"Successfully downloaded: {track.title} - {track.artists}",
|
||||
int((i + 1) / total_tracks * 100))
|
||||
self.successful_tracks.append(track)
|
||||
except Exception as 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)}",
|
||||
@@ -298,10 +266,14 @@ class DownloadWorker(QThread):
|
||||
success_message = "Download completed!"
|
||||
if self.failed_tracks:
|
||||
success_message += f"\n\nFailed downloads: {len(self.failed_tracks)} tracks"
|
||||
self.finished.emit(True, success_message, self.failed_tracks)
|
||||
if self.successful_tracks:
|
||||
success_message += f"\n\nSuccessful downloads: {len(self.successful_tracks)} tracks"
|
||||
if self.skipped_tracks:
|
||||
success_message += f"\n\nSkipped (already exists): {len(self.skipped_tracks)} tracks"
|
||||
self.finished.emit(True, success_message, self.failed_tracks, self.successful_tracks, self.skipped_tracks)
|
||||
|
||||
except Exception as e:
|
||||
self.finished.emit(False, str(e), self.failed_tracks)
|
||||
self.finished.emit(False, str(e), self.failed_tracks, self.successful_tracks, self.skipped_tracks)
|
||||
|
||||
def pause(self):
|
||||
self.is_paused = True
|
||||
@@ -350,29 +322,13 @@ class TidalStatusChecker(QThread):
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
response = requests.get("https://hifi.401658.xyz", timeout=5)
|
||||
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 QobuzStatusChecker(QThread):
|
||||
status_updated = pyqtSignal(bool)
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, region="us"):
|
||||
super().__init__()
|
||||
self.region = region
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
response = requests.get(f"https://{self.region}.qobuz.squid.wtf", timeout=5)
|
||||
self.status_updated.emit(response.status_code == 200)
|
||||
except Exception as e:
|
||||
self.error.emit(f"Error checking Qobuz status: {str(e)}")
|
||||
self.status_updated.emit(False)
|
||||
|
||||
class DeezerStatusChecker(QThread):
|
||||
status_updated = pyqtSignal(bool)
|
||||
error = pyqtSignal(str)
|
||||
@@ -386,19 +342,6 @@ class DeezerStatusChecker(QThread):
|
||||
self.error.emit(f"Error checking Deezer status: {str(e)}")
|
||||
self.status_updated.emit(False)
|
||||
|
||||
class AmazonStatusChecker(QThread):
|
||||
status_updated = pyqtSignal(bool)
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
response = requests.get("https://lucida.to/api/load?url=%2Fapi%2Fcountries%3Fservice%3Damazon", timeout=5)
|
||||
is_online = response.status_code == 200
|
||||
self.status_updated.emit(is_online)
|
||||
except Exception as e:
|
||||
self.error.emit(f"Error checking Amazon Music status: {str(e)}")
|
||||
self.status_updated.emit(False)
|
||||
|
||||
class StatusIndicatorDelegate(QStyledItemDelegate):
|
||||
def paint(self, painter, option, index):
|
||||
item_data = index.data(Qt.ItemDataRole.UserRole)
|
||||
@@ -443,25 +386,14 @@ class ServiceComboBox(QComboBox):
|
||||
|
||||
self.deezer_status_timer = QTimer(self)
|
||||
self.deezer_status_timer.timeout.connect(self.refresh_deezer_status)
|
||||
self.deezer_status_timer.start(60000)
|
||||
|
||||
self.amazon_status_checker = AmazonStatusChecker()
|
||||
self.amazon_status_checker.status_updated.connect(self.update_amazon_service_status)
|
||||
self.amazon_status_checker.error.connect(lambda e: print(f"Amazon Music status check error: {e}"))
|
||||
self.amazon_status_checker.start()
|
||||
|
||||
self.amazon_status_timer = QTimer(self)
|
||||
self.amazon_status_timer.timeout.connect(self.refresh_amazon_status)
|
||||
self.amazon_status_timer.start(60000)
|
||||
self.deezer_status_timer.start(60000)
|
||||
|
||||
def setup_items(self):
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
self.services = [
|
||||
{'id': 'qobuz', 'name': 'Qobuz', 'icon': 'qobuz.png', 'online': False},
|
||||
{'id': 'tidal', 'name': 'Tidal', 'icon': 'tidal.png', 'online': False},
|
||||
{'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png', 'online': False},
|
||||
{'id': 'amazon', 'name': 'Amazon Music', 'icon': 'amazon.png', 'online': False}
|
||||
{'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png', 'online': False}
|
||||
]
|
||||
|
||||
for service in self.services:
|
||||
@@ -517,119 +449,16 @@ class ServiceComboBox(QComboBox):
|
||||
self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}"))
|
||||
self.deezer_status_checker.start()
|
||||
|
||||
def update_amazon_service_status(self, is_online):
|
||||
self.update_service_status('amazon', is_online)
|
||||
|
||||
def refresh_amazon_status(self):
|
||||
if hasattr(self, 'amazon_status_checker') and self.amazon_status_checker.isRunning():
|
||||
self.amazon_status_checker.quit()
|
||||
self.amazon_status_checker.wait()
|
||||
|
||||
self.amazon_status_checker = AmazonStatusChecker()
|
||||
self.amazon_status_checker.status_updated.connect(self.update_amazon_service_status)
|
||||
self.amazon_status_checker.error.connect(lambda e: print(f"Amazon Music status check error: {e}"))
|
||||
self.amazon_status_checker.start()
|
||||
|
||||
def currentData(self, role=Qt.ItemDataRole.UserRole + 1):
|
||||
return super().currentData(role)
|
||||
|
||||
def update_qobuz_status(self, region_id, is_online):
|
||||
for i in range(self.count()):
|
||||
service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
||||
|
||||
if service_id == 'qobuz':
|
||||
service_data = self.itemData(i, Qt.ItemDataRole.UserRole)
|
||||
if isinstance(service_data, dict):
|
||||
if is_online or service_data.get('online', False):
|
||||
service_data['online'] = True
|
||||
self.setItemData(i, service_data, Qt.ItemDataRole.UserRole)
|
||||
break
|
||||
|
||||
self.update()
|
||||
|
||||
class QobuzRegionComboBox(QComboBox):
|
||||
status_updated = pyqtSignal(str, bool)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setIconSize(QSize(16, 16))
|
||||
|
||||
self.setItemDelegate(StatusIndicatorDelegate())
|
||||
|
||||
self.setup_items()
|
||||
|
||||
self.status_checkers = {}
|
||||
self.check_status()
|
||||
|
||||
self.status_timer = QTimer(self)
|
||||
self.status_timer.timeout.connect(self.check_status)
|
||||
self.status_timer.start(60000)
|
||||
|
||||
def setup_items(self):
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
self.regions = [
|
||||
{'id': 'eu', 'name': 'Europe', 'icon': 'eu.svg', 'online': False},
|
||||
{'id': 'us', 'name': 'North America', 'icon': 'us.svg', 'online': False}
|
||||
]
|
||||
|
||||
for region in self.regions:
|
||||
icon_path = os.path.join(current_dir, region['icon'])
|
||||
if not os.path.exists(icon_path):
|
||||
self.create_placeholder_icon(icon_path)
|
||||
|
||||
icon = QIcon(icon_path)
|
||||
|
||||
self.addItem(icon, region['name'])
|
||||
item_index = self.count() - 1
|
||||
self.setItemData(item_index, region['id'], Qt.ItemDataRole.UserRole + 1)
|
||||
self.setItemData(item_index, region, Qt.ItemDataRole.UserRole)
|
||||
|
||||
def create_placeholder_icon(self, path):
|
||||
pixmap = QPixmap(16, 16)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
pixmap.save(path)
|
||||
|
||||
def update_region_status(self, region_id, is_online):
|
||||
for i in range(self.count()):
|
||||
current_region_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
||||
|
||||
if current_region_id == region_id:
|
||||
region_data = self.itemData(i, Qt.ItemDataRole.UserRole)
|
||||
if isinstance(region_data, dict):
|
||||
region_data['online'] = is_online
|
||||
self.setItemData(i, region_data, Qt.ItemDataRole.UserRole)
|
||||
break
|
||||
|
||||
self.update()
|
||||
|
||||
def check_status(self):
|
||||
for region_id, checker in self.status_checkers.items():
|
||||
if checker.isRunning():
|
||||
checker.quit()
|
||||
checker.wait()
|
||||
self.status_checkers.clear()
|
||||
|
||||
for region in self.regions:
|
||||
region_id = region['id']
|
||||
checker = QobuzStatusChecker(region_id)
|
||||
checker.status_updated.connect(lambda status, rid=region_id: self.handle_status_update(rid, status))
|
||||
checker.start()
|
||||
self.status_checkers[region_id] = checker
|
||||
|
||||
def handle_status_update(self, region_id, is_online):
|
||||
self.update_region_status(region_id, is_online)
|
||||
self.status_updated.emit(region_id, is_online)
|
||||
|
||||
def currentData(self, role=Qt.ItemDataRole.UserRole + 1):
|
||||
return super().currentData(role)
|
||||
|
||||
class SpotiFLACGUI(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.current_version = "4.4"
|
||||
self.current_version = "4.7"
|
||||
self.tracks = []
|
||||
self.all_tracks = []
|
||||
self.successful_downloads = []
|
||||
self.reset_state()
|
||||
|
||||
self.settings = QSettings('SpotiFLAC', 'Settings')
|
||||
@@ -641,9 +470,10 @@ class SpotiFLACGUI(QWidget):
|
||||
self.use_artist_subfolders = self.settings.value('use_artist_subfolders', False, type=bool)
|
||||
self.use_album_subfolders = self.settings.value('use_album_subfolders', False, type=bool)
|
||||
self.service = self.settings.value('service', 'tidal')
|
||||
self.qobuz_region = self.settings.value('qobuz_region', 'us')
|
||||
self.check_for_updates = self.settings.value('check_for_updates', True, type=bool)
|
||||
self.current_theme_color = self.settings.value('theme_color', '#2196F3')
|
||||
self.track_list_format = self.settings.value('track_list_format', 'track_artist_date_duration')
|
||||
self.date_format = self.settings.value('date_format', 'dd_mm_yyyy')
|
||||
|
||||
self.elapsed_time = QTime(0, 0, 0)
|
||||
self.timer = QTimer(self)
|
||||
@@ -662,6 +492,9 @@ class SpotiFLACGUI(QWidget):
|
||||
if combobox.itemData(i, Qt.ItemDataRole.UserRole + 1) == target_value:
|
||||
combobox.setCurrentIndex(i)
|
||||
return True
|
||||
if combobox.itemData(i, Qt.ItemDataRole.UserRole) == target_value:
|
||||
combobox.setCurrentIndex(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_updates(self):
|
||||
@@ -762,11 +595,74 @@ class SpotiFLACGUI(QWidget):
|
||||
|
||||
self.update_track_list_display()
|
||||
|
||||
def format_track_date(self, release_date):
|
||||
if not release_date:
|
||||
return ""
|
||||
|
||||
try:
|
||||
if len(release_date) == 4:
|
||||
date_obj = datetime.strptime(release_date, "%Y")
|
||||
if self.date_format == "yyyy":
|
||||
return date_obj.strftime('%Y')
|
||||
else:
|
||||
return date_obj.strftime('%Y')
|
||||
elif len(release_date) == 7:
|
||||
date_obj = datetime.strptime(release_date, "%Y-%m")
|
||||
if self.date_format == "dd_mm_yyyy":
|
||||
return date_obj.strftime('%m-%Y')
|
||||
elif self.date_format == "yyyy_mm_dd":
|
||||
return date_obj.strftime('%Y-%m')
|
||||
else:
|
||||
return date_obj.strftime('%Y')
|
||||
else:
|
||||
date_obj = datetime.strptime(release_date, "%Y-%m-%d")
|
||||
if self.date_format == "dd_mm_yyyy":
|
||||
return date_obj.strftime('%d-%m-%Y')
|
||||
elif self.date_format == "yyyy_mm_dd":
|
||||
return date_obj.strftime('%Y-%m-%d')
|
||||
else:
|
||||
return date_obj.strftime('%Y')
|
||||
except ValueError:
|
||||
return release_date
|
||||
|
||||
def update_track_list_display(self):
|
||||
self.track_list.clear()
|
||||
for i, track in enumerate(self.tracks, 1):
|
||||
duration = self.format_duration(track.duration_ms)
|
||||
self.track_list.addItem(f"{i}. {track.title} - {track.artists} • {duration}")
|
||||
formatted_date = self.format_track_date(track.release_date)
|
||||
|
||||
if self.track_list_format == "artist_track_date_duration":
|
||||
display_parts = [f"{i}. {track.artists} - {track.title}"]
|
||||
if formatted_date:
|
||||
display_parts.append(formatted_date)
|
||||
display_parts.append(duration)
|
||||
display_text = " • ".join(display_parts)
|
||||
elif self.track_list_format == "track_artist_date":
|
||||
display_parts = [f"{i}. {track.title} - {track.artists}"]
|
||||
if formatted_date:
|
||||
display_parts.append(formatted_date)
|
||||
display_text = " • ".join(display_parts)
|
||||
elif self.track_list_format == "artist_track_date":
|
||||
display_parts = [f"{i}. {track.artists} - {track.title}"]
|
||||
if formatted_date:
|
||||
display_parts.append(formatted_date)
|
||||
display_text = " • ".join(display_parts)
|
||||
elif self.track_list_format == "track_artist_duration":
|
||||
display_text = f"{i}. {track.title} - {track.artists} • {duration}"
|
||||
elif self.track_list_format == "artist_track_duration":
|
||||
display_text = f"{i}. {track.artists} - {track.title} • {duration}"
|
||||
elif self.track_list_format == "track_artist":
|
||||
display_text = f"{i}. {track.title} - {track.artists}"
|
||||
elif self.track_list_format == "artist_track":
|
||||
display_text = f"{i}. {track.artists} - {track.title}"
|
||||
else:
|
||||
display_parts = [f"{i}. {track.title} - {track.artists}"]
|
||||
if formatted_date:
|
||||
display_parts.append(formatted_date)
|
||||
display_parts.append(duration)
|
||||
display_text = " • ".join(display_parts)
|
||||
|
||||
self.track_list.addItem(display_text)
|
||||
|
||||
def browse_output(self):
|
||||
directory = QFileDialog.getExistingDirectory(self, "Select Output Directory")
|
||||
@@ -944,9 +840,15 @@ class SpotiFLACGUI(QWidget):
|
||||
self.stop_btn.clicked.connect(self.stop_download)
|
||||
self.pause_resume_btn.clicked.connect(self.toggle_pause_resume)
|
||||
|
||||
self.remove_successful_btn = QPushButton('Remove Finished Songs')
|
||||
self.remove_successful_btn.setFixedWidth(200)
|
||||
self.remove_successful_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.remove_successful_btn.clicked.connect(self.remove_successful_downloads)
|
||||
|
||||
control_layout.addStretch()
|
||||
control_layout.addWidget(self.stop_btn)
|
||||
control_layout.addWidget(self.pause_resume_btn)
|
||||
control_layout.addWidget(self.remove_successful_btn)
|
||||
control_layout.addStretch()
|
||||
|
||||
process_layout.addLayout(control_layout)
|
||||
@@ -959,19 +861,21 @@ class SpotiFLACGUI(QWidget):
|
||||
self.time_label.hide()
|
||||
self.stop_btn.hide()
|
||||
self.pause_resume_btn.hide()
|
||||
self.remove_successful_btn.hide()
|
||||
|
||||
def setup_settings_tab(self):
|
||||
settings_tab = QWidget()
|
||||
settings_layout = QVBoxLayout()
|
||||
settings_layout.setSpacing(10)
|
||||
settings_layout.setContentsMargins(9, 9, 9, 9)
|
||||
settings_layout.setSpacing(4)
|
||||
settings_layout.setContentsMargins(10, 10, 10, 10)
|
||||
|
||||
output_group = QWidget()
|
||||
output_layout = QVBoxLayout(output_group)
|
||||
output_layout.setSpacing(5)
|
||||
output_layout.setSpacing(2)
|
||||
output_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
output_label = QLabel('Output Directory')
|
||||
output_label.setStyleSheet("font-weight: bold;")
|
||||
output_label.setStyleSheet("font-weight: bold; margin-top: 0px; margin-bottom: 5px;")
|
||||
output_layout.addWidget(output_label)
|
||||
|
||||
output_dir_layout = QHBoxLayout()
|
||||
@@ -985,18 +889,67 @@ class SpotiFLACGUI(QWidget):
|
||||
self.output_browse.clicked.connect(self.browse_output)
|
||||
|
||||
output_dir_layout.addWidget(self.output_dir)
|
||||
output_dir_layout.addSpacing(5)
|
||||
output_dir_layout.addWidget(self.output_browse)
|
||||
|
||||
output_layout.addLayout(output_dir_layout)
|
||||
|
||||
settings_layout.addWidget(output_group)
|
||||
|
||||
dashboard_group = QWidget()
|
||||
dashboard_layout = QVBoxLayout(dashboard_group)
|
||||
dashboard_layout.setSpacing(3)
|
||||
dashboard_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
dashboard_label = QLabel('Dashboard Settings')
|
||||
dashboard_label.setStyleSheet("font-weight: bold; margin-top: 8px; margin-bottom: 5px;")
|
||||
dashboard_layout.addWidget(dashboard_label)
|
||||
|
||||
dashboard_controls_layout = QHBoxLayout()
|
||||
|
||||
list_format_label = QLabel('Track List View:')
|
||||
list_format_label.setFixedWidth(90)
|
||||
|
||||
self.track_list_format_dropdown = QComboBox()
|
||||
self.track_list_format_dropdown.addItem("Track - Artist - Date - Duration", "track_artist_date_duration")
|
||||
self.track_list_format_dropdown.addItem("Artist - Track - Date - Duration", "artist_track_date_duration")
|
||||
self.track_list_format_dropdown.addItem("Track - Artist - Date", "track_artist_date")
|
||||
self.track_list_format_dropdown.addItem("Artist - Track - Date", "artist_track_date")
|
||||
self.track_list_format_dropdown.addItem("Track - Artist - Duration", "track_artist_duration")
|
||||
self.track_list_format_dropdown.addItem("Artist - Track - Duration", "artist_track_duration")
|
||||
self.track_list_format_dropdown.addItem("Track - Artist", "track_artist")
|
||||
self.track_list_format_dropdown.addItem("Artist - Track", "artist_track")
|
||||
self.track_list_format_dropdown.currentIndexChanged.connect(self.save_track_list_format)
|
||||
|
||||
dashboard_controls_layout.addWidget(list_format_label)
|
||||
dashboard_controls_layout.addWidget(self.track_list_format_dropdown)
|
||||
|
||||
dashboard_controls_layout.addSpacing(15)
|
||||
|
||||
date_format_label = QLabel('Date Format:')
|
||||
date_format_label.setFixedWidth(80)
|
||||
|
||||
self.date_format_dropdown = QComboBox()
|
||||
self.date_format_dropdown.addItem("DD-MM-YYYY", "dd_mm_yyyy")
|
||||
self.date_format_dropdown.addItem("YYYY-MM-DD", "yyyy_mm_dd")
|
||||
self.date_format_dropdown.addItem("YYYY", "yyyy")
|
||||
self.date_format_dropdown.currentIndexChanged.connect(self.save_date_format)
|
||||
|
||||
dashboard_controls_layout.addWidget(date_format_label)
|
||||
dashboard_controls_layout.addWidget(self.date_format_dropdown)
|
||||
dashboard_controls_layout.addStretch()
|
||||
|
||||
dashboard_layout.addLayout(dashboard_controls_layout)
|
||||
|
||||
settings_layout.addWidget(dashboard_group)
|
||||
|
||||
file_group = QWidget()
|
||||
file_layout = QVBoxLayout(file_group)
|
||||
file_layout.setSpacing(5)
|
||||
file_layout.setSpacing(2)
|
||||
file_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
file_label = QLabel('File Settings')
|
||||
file_label.setStyleSheet("font-weight: bold;")
|
||||
file_label.setStyleSheet("font-weight: bold; margin-top: 8px; margin-bottom: 5px;")
|
||||
file_layout.addWidget(file_label)
|
||||
|
||||
format_layout = QHBoxLayout()
|
||||
@@ -1027,7 +980,9 @@ class SpotiFLACGUI(QWidget):
|
||||
|
||||
format_layout.addWidget(format_label)
|
||||
format_layout.addWidget(self.title_artist_radio)
|
||||
format_layout.addSpacing(10)
|
||||
format_layout.addWidget(self.artist_title_radio)
|
||||
format_layout.addSpacing(10)
|
||||
format_layout.addWidget(self.title_only_radio)
|
||||
format_layout.addStretch()
|
||||
file_layout.addLayout(format_layout)
|
||||
@@ -1039,14 +994,16 @@ class SpotiFLACGUI(QWidget):
|
||||
self.artist_subfolder_checkbox.setChecked(self.use_artist_subfolders)
|
||||
self.artist_subfolder_checkbox.toggled.connect(self.save_artist_subfolder_setting)
|
||||
checkbox_layout.addWidget(self.artist_subfolder_checkbox)
|
||||
checkbox_layout.addSpacing(10)
|
||||
|
||||
self.album_subfolder_checkbox = QCheckBox('Album Subfolder (Playlist)')
|
||||
self.album_subfolder_checkbox.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.album_subfolder_checkbox.setChecked(self.use_album_subfolders)
|
||||
self.album_subfolder_checkbox.toggled.connect(self.save_album_subfolder_setting)
|
||||
checkbox_layout.addWidget(self.album_subfolder_checkbox)
|
||||
checkbox_layout.addSpacing(10)
|
||||
|
||||
self.track_number_checkbox = QCheckBox('Track Number for Album')
|
||||
self.track_number_checkbox = QCheckBox('Track Number')
|
||||
self.track_number_checkbox.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.track_number_checkbox.setChecked(self.use_track_numbers)
|
||||
self.track_number_checkbox.toggled.connect(self.save_track_numbering)
|
||||
@@ -1059,10 +1016,11 @@ class SpotiFLACGUI(QWidget):
|
||||
|
||||
auth_group = QWidget()
|
||||
auth_layout = QVBoxLayout(auth_group)
|
||||
auth_layout.setSpacing(5)
|
||||
auth_layout.setSpacing(2)
|
||||
auth_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
auth_label = QLabel('Service Settings')
|
||||
auth_label.setStyleSheet("font-weight: bold;")
|
||||
auth_label.setStyleSheet("font-weight: bold; margin-top: 8px; margin-bottom: 5px;")
|
||||
auth_layout.addWidget(auth_label)
|
||||
|
||||
service_fallback_layout = QHBoxLayout()
|
||||
@@ -1074,17 +1032,6 @@ class SpotiFLACGUI(QWidget):
|
||||
service_fallback_layout.addWidget(service_label)
|
||||
service_fallback_layout.addWidget(self.service_dropdown)
|
||||
|
||||
service_fallback_layout.addSpacing(10)
|
||||
|
||||
region_label = QLabel('Region:')
|
||||
self.qobuz_region_dropdown = QobuzRegionComboBox()
|
||||
self.qobuz_region_dropdown.currentIndexChanged.connect(self.save_qobuz_region_setting)
|
||||
service_fallback_layout.addWidget(region_label)
|
||||
service_fallback_layout.addWidget(self.qobuz_region_dropdown)
|
||||
|
||||
region_label.hide()
|
||||
self.qobuz_region_dropdown.hide()
|
||||
|
||||
service_fallback_layout.addStretch()
|
||||
auth_layout.addLayout(service_fallback_layout)
|
||||
|
||||
@@ -1093,19 +1040,14 @@ class SpotiFLACGUI(QWidget):
|
||||
settings_tab.setLayout(settings_layout)
|
||||
self.tab_widget.addTab(settings_tab, "Settings")
|
||||
self.set_combobox_value(self.service_dropdown, self.service)
|
||||
self.set_combobox_value(self.qobuz_region_dropdown, self.qobuz_region)
|
||||
|
||||
self.update_service_ui()
|
||||
|
||||
self.qobuz_region_dropdown.status_updated.connect(
|
||||
lambda region_id, is_online: self.service_dropdown.update_qobuz_status(region_id, is_online)
|
||||
)
|
||||
self.set_combobox_value(self.track_list_format_dropdown, self.track_list_format)
|
||||
self.set_combobox_value(self.date_format_dropdown, self.date_format)
|
||||
|
||||
def setup_theme_tab(self):
|
||||
theme_tab = QWidget()
|
||||
theme_layout = QVBoxLayout()
|
||||
theme_layout.setSpacing(8)
|
||||
theme_layout.setContentsMargins(15, 15, 15, 15)
|
||||
theme_layout.setContentsMargins(8, 15, 15, 15)
|
||||
|
||||
grid_layout = QVBoxLayout()
|
||||
|
||||
@@ -1302,8 +1244,7 @@ class SpotiFLACGUI(QWidget):
|
||||
|
||||
about_layout.addWidget(section_widget)
|
||||
|
||||
footer_label = QLabel(f"v{self.current_version} | August 2025")
|
||||
footer_label.setStyleSheet("font-size: 12px; margin-top: 20px;")
|
||||
footer_label = QLabel(f"v{self.current_version} | October 2025")
|
||||
about_layout.addWidget(footer_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
about_tab.setLayout(about_layout)
|
||||
@@ -1314,32 +1255,8 @@ class SpotiFLACGUI(QWidget):
|
||||
self.service = service
|
||||
self.settings.setValue('service', service)
|
||||
self.settings.sync()
|
||||
|
||||
self.update_service_ui()
|
||||
self.log_output.append(f"Service changed to: {self.service_dropdown.currentText()}")
|
||||
|
||||
def update_service_ui(self):
|
||||
service = self.service
|
||||
|
||||
region_label = None
|
||||
for widget in self.qobuz_region_dropdown.parentWidget().children():
|
||||
if isinstance(widget, QLabel) and widget.text() == "Region:":
|
||||
region_label = widget
|
||||
break
|
||||
|
||||
if service == "qobuz":
|
||||
if region_label:
|
||||
region_label.show()
|
||||
self.qobuz_region_dropdown.show()
|
||||
elif service == "deezer":
|
||||
if region_label:
|
||||
region_label.hide()
|
||||
self.qobuz_region_dropdown.hide()
|
||||
else:
|
||||
if region_label:
|
||||
region_label.hide()
|
||||
self.qobuz_region_dropdown.hide()
|
||||
|
||||
def save_url(self):
|
||||
self.settings.setValue('spotify_url', self.spotify_url.text().strip())
|
||||
self.settings.sync()
|
||||
@@ -1369,12 +1286,21 @@ class SpotiFLACGUI(QWidget):
|
||||
self.settings.setValue('use_album_subfolders', self.use_album_subfolders)
|
||||
self.settings.sync()
|
||||
|
||||
def save_qobuz_region_setting(self):
|
||||
region = self.qobuz_region_dropdown.currentData()
|
||||
self.qobuz_region = region
|
||||
self.settings.setValue('qobuz_region', region)
|
||||
def save_track_list_format(self):
|
||||
format_value = self.track_list_format_dropdown.currentData()
|
||||
self.track_list_format = format_value
|
||||
self.settings.setValue('track_list_format', format_value)
|
||||
self.settings.sync()
|
||||
self.log_output.append(f"Qobuz region setting saved: {self.qobuz_region_dropdown.currentText()}")
|
||||
if self.tracks:
|
||||
self.update_track_list_display()
|
||||
|
||||
def save_date_format(self):
|
||||
format_value = self.date_format_dropdown.currentData()
|
||||
self.date_format = format_value
|
||||
self.settings.setValue('date_format', format_value)
|
||||
self.settings.sync()
|
||||
if self.tracks:
|
||||
self.update_track_list_display()
|
||||
|
||||
def save_settings(self):
|
||||
self.settings.setValue('output_path', self.output_dir.text().strip())
|
||||
@@ -1417,6 +1343,10 @@ class SpotiFLACGUI(QWidget):
|
||||
self.handle_album_metadata(metadata)
|
||||
elif url_info["type"] == "playlist":
|
||||
self.handle_playlist_metadata(metadata)
|
||||
elif url_info["type"] == "artist_discography":
|
||||
self.handle_discography_metadata(metadata)
|
||||
elif url_info["type"] == "artist":
|
||||
self.handle_artist_metadata(metadata)
|
||||
|
||||
self.update_button_states()
|
||||
self.tab_widget.setCurrentIndex(0)
|
||||
@@ -1437,7 +1367,8 @@ class SpotiFLACGUI(QWidget):
|
||||
track_number=1,
|
||||
duration_ms=track_data.get("duration_ms", 0),
|
||||
id=track_id,
|
||||
isrc=track_data.get("isrc", "")
|
||||
isrc=track_data.get("isrc", ""),
|
||||
release_date=track_data.get("release_date", "")
|
||||
)
|
||||
|
||||
self.tracks = [track]
|
||||
@@ -1470,7 +1401,8 @@ class SpotiFLACGUI(QWidget):
|
||||
track_number=track["track_number"],
|
||||
duration_ms=track.get("duration_ms", 0),
|
||||
id=track_id,
|
||||
isrc=track.get("isrc", "")
|
||||
isrc=track.get("isrc", ""),
|
||||
release_date=track.get("release_date", "")
|
||||
))
|
||||
|
||||
self.all_tracks = self.tracks.copy()
|
||||
@@ -1501,7 +1433,8 @@ class SpotiFLACGUI(QWidget):
|
||||
track_number=track.get("track_number", len(self.tracks) + 1),
|
||||
duration_ms=track.get("duration_ms", 0),
|
||||
id=track_id,
|
||||
isrc=track.get("isrc", "")
|
||||
isrc=track.get("isrc", ""),
|
||||
release_date=track.get("release_date", "")
|
||||
))
|
||||
|
||||
self.all_tracks = self.tracks.copy()
|
||||
@@ -1513,9 +1446,57 @@ class SpotiFLACGUI(QWidget):
|
||||
'artists': playlist_data["playlist_info"]["owner"]["display_name"],
|
||||
'cover': playlist_data["playlist_info"]["owner"]["images"],
|
||||
'followers': playlist_data["playlist_info"]["followers"]["total"],
|
||||
'total_tracks': playlist_data["playlist_info"]["tracks"]["total"] }
|
||||
'total_tracks': playlist_data["playlist_info"]["tracks"]["total"]
|
||||
}
|
||||
self.update_display_after_fetch(metadata)
|
||||
|
||||
def handle_discography_metadata(self, discography_data):
|
||||
artist_info = discography_data["artist_info"]
|
||||
self.album_or_playlist_name = f"{artist_info['name']} - Discography ({artist_info['discography_type'].title()})"
|
||||
self.tracks = []
|
||||
|
||||
for track in discography_data["track_list"]:
|
||||
track_id = track["external_urls"].split("/")[-1] if track.get("external_urls") else ""
|
||||
|
||||
self.tracks.append(Track(
|
||||
external_urls=track.get("external_urls", ""),
|
||||
title=track["name"],
|
||||
artists=track["artists"],
|
||||
album=track["album_name"],
|
||||
track_number=track.get("track_number", len(self.tracks) + 1),
|
||||
duration_ms=track.get("duration_ms", 0),
|
||||
id=track_id,
|
||||
isrc=track.get("isrc", ""),
|
||||
release_date=track.get("release_date", "")
|
||||
))
|
||||
|
||||
self.all_tracks = self.tracks.copy()
|
||||
self.is_playlist = True
|
||||
self.is_album = self.is_single_track = False
|
||||
|
||||
metadata = {
|
||||
'title': f"{artist_info['name']} - Discography",
|
||||
'artists': f"{artist_info['discography_type'].title()} • {artist_info['total_albums']} albums",
|
||||
'cover': artist_info["images"],
|
||||
'followers': artist_info.get("followers", 0),
|
||||
'total_tracks': len(self.tracks),
|
||||
'discography_type': artist_info['discography_type']
|
||||
}
|
||||
self.update_display_after_fetch(metadata)
|
||||
|
||||
def handle_artist_metadata(self, artist_data):
|
||||
self.reset_state()
|
||||
|
||||
metadata = {
|
||||
'title': artist_data["artist"]["name"],
|
||||
'artists': f"Followers: {artist_data['artist']['followers']:,}",
|
||||
'cover': artist_data["artist"]["images"],
|
||||
'followers': artist_data["artist"]["followers"],
|
||||
'genres': artist_data["artist"].get("genres", [])
|
||||
}
|
||||
|
||||
self.update_info_widget_artist_only(metadata)
|
||||
|
||||
def update_display_after_fetch(self, metadata):
|
||||
self.track_list.setVisible(not self.is_single_track)
|
||||
|
||||
@@ -1571,12 +1552,40 @@ class SpotiFLACGUI(QWidget):
|
||||
self.type_label.setText(f"<b>Album</b> • {total_tracks} tracks")
|
||||
elif self.is_playlist:
|
||||
total_tracks = metadata.get('total_tracks', 0)
|
||||
self.type_label.setText(f"<b>Playlist</b> • {total_tracks} tracks")
|
||||
if metadata.get('discography_type'):
|
||||
discography_type = metadata['discography_type'].title()
|
||||
self.type_label.setText(f"<b>Discography ({discography_type})</b> • {total_tracks} tracks")
|
||||
else:
|
||||
self.type_label.setText(f"<b>Playlist</b> • {total_tracks} tracks")
|
||||
|
||||
self.network_manager.get(QNetworkRequest(QUrl(metadata['cover'])))
|
||||
|
||||
self.info_widget.show()
|
||||
|
||||
def update_info_widget_artist_only(self, metadata):
|
||||
self.title_label.setText(metadata['title'])
|
||||
self.artists_label.setText(f"<b>Followers</b> {metadata['followers']:,}")
|
||||
|
||||
if metadata.get('genres'):
|
||||
genres_text = ", ".join(metadata['genres'][:3])
|
||||
if len(metadata['genres']) > 3:
|
||||
genres_text += f" (+{len(metadata['genres']) - 3} more)"
|
||||
self.followers_label.setText(f"<b>Genres</b> {genres_text}")
|
||||
self.followers_label.show()
|
||||
else:
|
||||
self.followers_label.hide()
|
||||
|
||||
self.release_date_label.hide()
|
||||
self.type_label.setText("<b>Artist Profile</b> • No tracks available for download")
|
||||
|
||||
self.network_manager.get(QNetworkRequest(QUrl(metadata['cover'])))
|
||||
|
||||
self.track_list.hide()
|
||||
self.search_widget.hide()
|
||||
self.hide_track_buttons()
|
||||
|
||||
self.info_widget.show()
|
||||
|
||||
def reset_info_widget(self):
|
||||
self.title_label.clear()
|
||||
self.artists_label.clear()
|
||||
@@ -1673,7 +1682,6 @@ class SpotiFLACGUI(QWidget):
|
||||
|
||||
def start_download_worker(self, tracks_to_download, outpath):
|
||||
service = self.service_dropdown.currentData()
|
||||
qobuz_region = self.qobuz_region_dropdown.currentData() if service == "qobuz" else "us"
|
||||
|
||||
self.worker = DownloadWorker(
|
||||
tracks_to_download,
|
||||
@@ -1686,10 +1694,9 @@ class SpotiFLACGUI(QWidget):
|
||||
self.use_track_numbers,
|
||||
self.use_artist_subfolders,
|
||||
self.use_album_subfolders,
|
||||
service,
|
||||
qobuz_region
|
||||
service
|
||||
)
|
||||
self.worker.finished.connect(self.on_download_finished)
|
||||
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.start()
|
||||
self.start_timer()
|
||||
@@ -1721,15 +1728,25 @@ class SpotiFLACGUI(QWidget):
|
||||
if hasattr(self, 'worker'):
|
||||
self.worker.stop()
|
||||
self.stop_timer()
|
||||
self.on_download_finished(True, "Download stopped by user.", [])
|
||||
self.on_download_finished(True, "Download stopped by user.", [], [], [])
|
||||
|
||||
def on_download_finished(self, success, message, failed_tracks):
|
||||
def on_download_finished(self, success, message, failed_tracks, successful_tracks=None, skipped_tracks=None):
|
||||
self.progress_bar.hide()
|
||||
self.stop_btn.hide()
|
||||
self.pause_resume_btn.hide()
|
||||
self.pause_resume_btn.setText('Pause')
|
||||
self.stop_timer()
|
||||
|
||||
if successful_tracks is not None:
|
||||
self.successful_downloads = successful_tracks
|
||||
if skipped_tracks is not None:
|
||||
self.skipped_downloads = skipped_tracks
|
||||
|
||||
if (hasattr(self, 'successful_downloads') and self.successful_downloads) or (hasattr(self, 'skipped_downloads') and self.skipped_downloads):
|
||||
self.remove_successful_btn.show()
|
||||
else:
|
||||
self.remove_successful_btn.hide()
|
||||
|
||||
self.download_selected_btn.setEnabled(True)
|
||||
self.download_all_btn.setEnabled(True)
|
||||
|
||||
@@ -1760,6 +1777,59 @@ class SpotiFLACGUI(QWidget):
|
||||
self.worker.pause()
|
||||
self.pause_resume_btn.setText('Resume')
|
||||
|
||||
def remove_successful_downloads(self):
|
||||
successful_tracks = getattr(self, 'successful_downloads', [])
|
||||
skipped_tracks = getattr(self, 'skipped_downloads', [])
|
||||
|
||||
if not successful_tracks and not skipped_tracks:
|
||||
self.log_output.append("No downloaded or skipped tracks to remove.")
|
||||
return
|
||||
|
||||
tracks_to_remove = []
|
||||
|
||||
for track in self.tracks:
|
||||
for successful_track in successful_tracks:
|
||||
if (track.title == successful_track.title and
|
||||
track.artists == successful_track.artists and
|
||||
track.album == successful_track.album):
|
||||
tracks_to_remove.append(track)
|
||||
break
|
||||
|
||||
for track in self.tracks:
|
||||
for skipped_track in skipped_tracks:
|
||||
if (track.title == skipped_track.title and
|
||||
track.artists == skipped_track.artists and
|
||||
track.album == skipped_track.album):
|
||||
if track not in tracks_to_remove:
|
||||
tracks_to_remove.append(track)
|
||||
break
|
||||
|
||||
if tracks_to_remove:
|
||||
for track in tracks_to_remove:
|
||||
if track in self.tracks:
|
||||
self.tracks.remove(track)
|
||||
if track in self.all_tracks:
|
||||
self.all_tracks.remove(track)
|
||||
|
||||
self.update_track_list_display()
|
||||
successful_count = len([t for t in tracks_to_remove if t in successful_tracks])
|
||||
skipped_count = len([t for t in tracks_to_remove if t in skipped_tracks])
|
||||
|
||||
message = f"Removed {len(tracks_to_remove)} tracks from the list"
|
||||
if successful_count > 0:
|
||||
message += f" ({successful_count} downloaded"
|
||||
if skipped_count > 0:
|
||||
message += f", {skipped_count} already existed" if successful_count > 0 else f" ({skipped_count} already existed"
|
||||
if successful_count > 0 or skipped_count > 0:
|
||||
message += ")"
|
||||
|
||||
self.log_output.append(message + ".")
|
||||
self.tab_widget.setCurrentIndex(0)
|
||||
else:
|
||||
self.log_output.append("No matching tracks found in the current list.")
|
||||
|
||||
self.remove_successful_btn.hide()
|
||||
|
||||
def remove_selected_tracks(self):
|
||||
if not self.is_single_track:
|
||||
selected_items = self.track_list.selectedItems()
|
||||
@@ -1773,9 +1843,7 @@ class SpotiFLACGUI(QWidget):
|
||||
if track in self.all_tracks:
|
||||
self.all_tracks.remove(track)
|
||||
|
||||
if self.is_playlist:
|
||||
for i, track in enumerate(self.all_tracks, 1):
|
||||
track.track_number = i
|
||||
|
||||
|
||||
self.update_track_list_display()
|
||||
|
||||
@@ -1806,12 +1874,6 @@ class SpotiFLACGUI(QWidget):
|
||||
checker.quit()
|
||||
checker.wait()
|
||||
|
||||
if hasattr(self, 'qobuz_region_dropdown'):
|
||||
for checker in self.qobuz_region_dropdown.status_checkers.values():
|
||||
if checker.isRunning():
|
||||
checker.quit()
|
||||
checker.wait()
|
||||
|
||||
if hasattr(self, 'worker') and self.worker and self.worker.isRunning():
|
||||
self.worker.stop()
|
||||
self.worker.quit()
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB |
-123
@@ -1,123 +0,0 @@
|
||||
import requests
|
||||
import time
|
||||
import os
|
||||
import re
|
||||
import base64
|
||||
import urllib3
|
||||
from urllib.parse import unquote
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
def extract_data(html, patterns):
|
||||
for pattern in patterns:
|
||||
if match := re.search(pattern, html):
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def download_track(track_id, service="amazon", output_dir="."):
|
||||
client = requests.Session()
|
||||
client.verify = False
|
||||
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
|
||||
|
||||
try:
|
||||
spotify_url = f"https://open.spotify.com/track/{track_id}"
|
||||
params = {"url": spotify_url, "country": "auto", "to": service}
|
||||
|
||||
response = client.get("https://lucida.to", params=params, headers=headers, timeout=30)
|
||||
html = response.text
|
||||
|
||||
token = extract_data(html, [r'token:"([^"]+)"', r'"token"\s*:\s*"([^"]+)"'])
|
||||
url = extract_data(html, [r'"url":"([^"]+)"', r'url:"([^"]+)"'])
|
||||
expiry = extract_data(html, [r'tokenExpiry:(\d+)', r'"tokenExpiry"\s*:\s*(\d+)'])
|
||||
|
||||
if not (token and url):
|
||||
raise Exception("Could not extract required data")
|
||||
|
||||
try:
|
||||
decoded_token = base64.b64decode(base64.b64decode(token).decode('latin1')).decode('latin1')
|
||||
except:
|
||||
decoded_token = token
|
||||
|
||||
clean_url = url.replace('\\/', '/')
|
||||
print(f"Starting download for: {clean_url}")
|
||||
|
||||
request_data = {
|
||||
"account": {"id": "auto", "type": "country"},
|
||||
"compat": "false", "downscale": "original", "handoff": True,
|
||||
"metadata": True, "private": True,
|
||||
"token": {"primary": decoded_token, "expiry": int(expiry) if expiry else None},
|
||||
"upload": {"enabled": False, "service": "pixeldrain"},
|
||||
"url": clean_url
|
||||
}
|
||||
|
||||
response = client.post("https://lucida.to/api/load?url=/api/fetch/stream/v2",
|
||||
json=request_data, headers=headers)
|
||||
|
||||
if csrf_token := response.cookies.get('csrf_token'):
|
||||
headers['X-CSRF-Token'] = csrf_token
|
||||
|
||||
data = response.json()
|
||||
if not data.get("success"):
|
||||
raise Exception(f"Request failed: {data.get('error', 'Unknown error')}")
|
||||
|
||||
completion_url = f"https://{data['server']}.lucida.to/api/fetch/request/{data['handoff']}"
|
||||
print("Processing track...")
|
||||
|
||||
while True:
|
||||
resp = client.get(completion_url, headers=headers).json()
|
||||
if resp["status"] == "completed":
|
||||
print("Processing completed!")
|
||||
break
|
||||
elif resp["status"] == "error":
|
||||
raise Exception(f"Processing failed: {resp.get('message', 'Unknown error')}")
|
||||
elif progress := resp.get("progress"):
|
||||
percent = int((progress.get("current", 0) / progress.get("total", 100)) * 100)
|
||||
print(f"Progress: {percent}%")
|
||||
time.sleep(1)
|
||||
|
||||
download_url = f"https://{data['server']}.lucida.to/api/fetch/request/{data['handoff']}/download"
|
||||
response = client.get(download_url, stream=True, headers=headers)
|
||||
|
||||
file_name = "track.flac"
|
||||
if content_disp := response.headers.get('content-disposition'):
|
||||
if match := re.search(r'filename[*]?=([^;]+)', content_disp):
|
||||
raw_name = match.group(1).strip('"\'')
|
||||
file_name = unquote(raw_name[7:] if raw_name.startswith("UTF-8''") else raw_name)
|
||||
for char in '<>:"/\\|?*':
|
||||
file_name = file_name.replace(char, '')
|
||||
file_name = file_name.strip()
|
||||
|
||||
file_path = os.path.join(output_dir, file_name)
|
||||
print(f"Downloading: {file_name}")
|
||||
|
||||
with open(file_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
print(f"Download completed: {file_path}")
|
||||
return file_path
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
return None
|
||||
|
||||
class LucidaDownloader:
|
||||
def __init__(self):
|
||||
self.progress_callback = None
|
||||
|
||||
def set_progress_callback(self, callback):
|
||||
self.progress_callback = callback
|
||||
|
||||
def download(self, track_id, output_dir, is_paused_callback=None, is_stopped_callback=None):
|
||||
"""Download track using Lucida service"""
|
||||
try:
|
||||
return download_track(track_id, service="amazon", output_dir=output_dir)
|
||||
except Exception as e:
|
||||
raise Exception(f"Amazon Music download failed: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
track_id = "2plbrEY59IikOBgBGLjaoe"
|
||||
service = "amazon"
|
||||
|
||||
download_track(track_id, service)
|
||||
+5
-1
@@ -3,12 +3,16 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
from mutagen.flac import FLAC
|
||||
from random import randrange
|
||||
|
||||
def get_random_user_agent():
|
||||
return f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_{randrange(11, 15)}_{randrange(4, 9)}) AppleWebKit/{randrange(530, 537)}.{randrange(30, 37)} (KHTML, like Gecko) Chrome/{randrange(80, 105)}.0.{randrange(3000, 4500)}.{randrange(60, 125)} Safari/{randrange(530, 537)}.{randrange(30, 36)}"
|
||||
|
||||
class DeezerDownloader:
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
'User-Agent': get_random_user_agent()
|
||||
})
|
||||
self.progress_callback = None
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-eu" viewBox="0 0 640 480">
|
||||
<defs>
|
||||
<g id="eu-d">
|
||||
<g id="eu-b">
|
||||
<path id="eu-a" d="m0-1-.3 1 .5.1z"/>
|
||||
<use xlink:href="#eu-a" transform="scale(-1 1)"/>
|
||||
</g>
|
||||
<g id="eu-c">
|
||||
<use xlink:href="#eu-b" transform="rotate(72)"/>
|
||||
<use xlink:href="#eu-b" transform="rotate(144)"/>
|
||||
</g>
|
||||
<use xlink:href="#eu-c" transform="scale(-1 1)"/>
|
||||
</g>
|
||||
</defs>
|
||||
<path fill="#039" d="M0 0h640v480H0z"/>
|
||||
<g fill="#fc0" transform="translate(320 242.3)scale(23.7037)">
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" y="-6"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" y="6"/>
|
||||
<g id="eu-e">
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" x="-6"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(-144 -2.3 -2.1)"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(144 -2.1 -2.3)"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(72 -4.7 -2)"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(72 -5 .5)"/>
|
||||
</g>
|
||||
<use xlink:href="#eu-e" width="100%" height="100%" transform="scale(-1 1)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
+222
-2
@@ -57,8 +57,10 @@ token_url = 'https://open.spotify.com/api/token'
|
||||
playlist_base_url = 'https://api.spotify.com/v1/playlists/{}'
|
||||
album_base_url = 'https://api.spotify.com/v1/albums/{}'
|
||||
track_base_url = 'https://api.spotify.com/v1/tracks/{}'
|
||||
artist_base_url = 'https://api.spotify.com/v1/artists/{}'
|
||||
artist_albums_url = 'https://api.spotify.com/v1/artists/{}/albums'
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
'User-Agent': get_random_user_agent(),
|
||||
'Accept': 'application/json',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
@@ -97,11 +99,22 @@ def parse_uri(uri):
|
||||
if parts[1] == "embed":
|
||||
parts = parts[1:]
|
||||
|
||||
if len(parts) > 1 and parts[1].startswith("intl-"):
|
||||
parts = parts[1:]
|
||||
|
||||
l = len(parts)
|
||||
if l == 3 and parts[1] in ["album", "track", "playlist"]:
|
||||
if l == 3 and parts[1] in ["album", "track", "playlist", "artist"]:
|
||||
return {"type": parts[1], "id": parts[2]}
|
||||
if l == 5 and parts[3] == "playlist":
|
||||
return {"type": parts[3], "id": parts[4]}
|
||||
if l >= 4 and parts[1] == "artist" and len(parts) >= 4:
|
||||
if parts[3] == "discography":
|
||||
discography_type = "all"
|
||||
if len(parts) >= 5 and parts[4] in ["all", "album", "single", "compilation"]:
|
||||
discography_type = parts[4]
|
||||
return {"type": "artist_discography", "id": parts[2], "discography_type": discography_type}
|
||||
else:
|
||||
return {"type": "artist", "id": parts[2]}
|
||||
|
||||
raise SpotifyInvalidUrlException("ERROR: unable to determine Spotify URL type or type is unsupported.")
|
||||
|
||||
@@ -336,6 +349,69 @@ def get_raw_spotify_data(spotify_url, batch: bool = False, delay: float = 1.0):
|
||||
raw_data = track_data
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to get track data: {str(e)}"}
|
||||
|
||||
elif url_info["type"] == "artist_discography":
|
||||
try:
|
||||
artist_data = get_json_from_api(
|
||||
artist_base_url.format(url_info["id"]),
|
||||
access_token
|
||||
)
|
||||
if not artist_data:
|
||||
return {"error": "Failed to get artist data"}
|
||||
|
||||
discography_type = url_info.get("discography_type", "all")
|
||||
if discography_type == "all":
|
||||
include_groups = "album,single,compilation"
|
||||
else:
|
||||
include_groups = discography_type
|
||||
|
||||
albums = []
|
||||
albums_url = f'{artist_albums_url.format(url_info["id"])}?include_groups={include_groups}&limit=50'
|
||||
|
||||
if batch:
|
||||
albums, num_batches = fetch_tracks_in_batches(albums_url, access_token, 50, delay)
|
||||
raw_data = {
|
||||
"artist_info": artist_data,
|
||||
"albums": albums,
|
||||
"discography_type": discography_type,
|
||||
"_batch_count": num_batches,
|
||||
"_batch_enabled": True
|
||||
}
|
||||
else:
|
||||
while albums_url:
|
||||
album_data = get_json_from_api(albums_url, access_token)
|
||||
if not album_data:
|
||||
break
|
||||
|
||||
albums.extend(album_data['items'])
|
||||
albums_url = album_data.get('next')
|
||||
if albums_url and "&locale=" in albums_url:
|
||||
albums_url = albums_url.split("&locale=")[0]
|
||||
|
||||
raw_data = {
|
||||
"artist_info": artist_data,
|
||||
"albums": albums,
|
||||
"discography_type": discography_type,
|
||||
"_batch_enabled": False
|
||||
}
|
||||
|
||||
raw_data['_token'] = access_token
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to get artist discography data: {str(e)}"}
|
||||
|
||||
elif url_info["type"] == "artist":
|
||||
try:
|
||||
artist_data = get_json_from_api(
|
||||
artist_base_url.format(url_info["id"]),
|
||||
access_token
|
||||
)
|
||||
if not artist_data:
|
||||
return {"error": "Failed to get artist data"}
|
||||
|
||||
raw_data = artist_data
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to get artist data: {str(e)}"}
|
||||
|
||||
return raw_data
|
||||
|
||||
@@ -466,6 +542,134 @@ def format_playlist_data(playlist_data):
|
||||
"track_list": track_list
|
||||
}
|
||||
|
||||
def format_artist_discography_data(discography_data):
|
||||
artist_info = discography_data.get('artist_info', {})
|
||||
albums = discography_data.get('albums', [])
|
||||
access_token = discography_data.get('_token', '')
|
||||
|
||||
artist_image = ''
|
||||
if artist_info.get('images'):
|
||||
artist_image = artist_info.get('images', [{}])[0].get('url', '')
|
||||
|
||||
formatted_artist_info = {
|
||||
"name": artist_info.get('name', ''),
|
||||
"followers": artist_info.get('followers', {}).get('total', 0),
|
||||
"genres": artist_info.get('genres', []),
|
||||
"images": artist_image,
|
||||
"external_urls": artist_info.get('external_urls', {}).get('spotify', ''),
|
||||
"discography_type": discography_data.get('discography_type', 'all'),
|
||||
"total_albums": len(albums)
|
||||
}
|
||||
|
||||
if discography_data.get('_batch_enabled', False):
|
||||
formatted_artist_info["batch"] = f"{discography_data.get('_batch_count', 1)}"
|
||||
|
||||
album_list = []
|
||||
all_tracks = []
|
||||
|
||||
for album in albums:
|
||||
album_image = ''
|
||||
if album.get('images'):
|
||||
album_image = album.get('images', [{}])[0].get('url', '')
|
||||
|
||||
album_artists = []
|
||||
for artist in album.get('artists', []):
|
||||
album_artists.append(artist['name'])
|
||||
|
||||
album_info = {
|
||||
"id": album.get('id', ''),
|
||||
"name": album.get('name', ''),
|
||||
"album_type": album.get('album_type', ''),
|
||||
"release_date": album.get('release_date', ''),
|
||||
"total_tracks": album.get('total_tracks', 0),
|
||||
"artists": ", ".join(album_artists),
|
||||
"images": album_image,
|
||||
"external_urls": album.get('external_urls', {}).get('spotify', '')
|
||||
}
|
||||
|
||||
album_list.append(album_info)
|
||||
|
||||
if access_token and album.get('id'):
|
||||
try:
|
||||
album_tracks_data = get_json_from_api(
|
||||
f'{album_base_url.format(album.get("id"))}/tracks?limit=50',
|
||||
access_token
|
||||
)
|
||||
|
||||
if album_tracks_data:
|
||||
tracks = []
|
||||
tracks_url = f'{album_base_url.format(album.get("id"))}/tracks?limit=50'
|
||||
|
||||
while tracks_url:
|
||||
track_data = get_json_from_api(tracks_url, access_token)
|
||||
if not track_data:
|
||||
break
|
||||
|
||||
tracks.extend(track_data['items'])
|
||||
tracks_url = track_data.get('next')
|
||||
if tracks_url and "&locale=" in tracks_url:
|
||||
tracks_url = tracks_url.split("&locale=")[0]
|
||||
|
||||
for track in tracks:
|
||||
track_artists = []
|
||||
for artist in track.get('artists', []):
|
||||
track_artists.append(artist['name'])
|
||||
|
||||
track_id = track.get('id', '')
|
||||
track_isrc = ''
|
||||
|
||||
if track_id:
|
||||
try:
|
||||
full_track_data = get_json_from_api(
|
||||
track_base_url.format(track_id),
|
||||
access_token
|
||||
)
|
||||
if full_track_data:
|
||||
track_isrc = full_track_data.get('external_ids', {}).get('isrc', '')
|
||||
except:
|
||||
pass
|
||||
|
||||
formatted_track = {
|
||||
"artists": ", ".join(track_artists),
|
||||
"name": track.get('name', ''),
|
||||
"album_name": album.get('name', ''),
|
||||
"album_type": album.get('album_type', ''),
|
||||
"duration_ms": track.get('duration_ms', 0),
|
||||
"images": album_image,
|
||||
"release_date": album.get('release_date', ''),
|
||||
"track_number": track.get('track_number', 0),
|
||||
"external_urls": track.get('external_urls', {}).get('spotify', ''),
|
||||
"isrc": track_isrc
|
||||
}
|
||||
|
||||
all_tracks.append(formatted_track)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting tracks for album {album.get('name', '')}: {str(e)}")
|
||||
continue
|
||||
|
||||
return {
|
||||
"artist_info": formatted_artist_info,
|
||||
"album_list": album_list,
|
||||
"track_list": all_tracks
|
||||
}
|
||||
|
||||
def format_artist_data(artist_data):
|
||||
artist_image = ''
|
||||
if artist_data.get('images'):
|
||||
artist_image = artist_data.get('images', [{}])[0].get('url', '')
|
||||
|
||||
return {
|
||||
"artist": {
|
||||
"name": artist_data.get('name', ''),
|
||||
"followers": artist_data.get('followers', {}).get('total', 0),
|
||||
"genres": artist_data.get('genres', []),
|
||||
"images": artist_image,
|
||||
"external_urls": artist_data.get('external_urls', {}).get('spotify', ''),
|
||||
"popularity": artist_data.get('popularity', 0)
|
||||
}
|
||||
}
|
||||
|
||||
def process_spotify_data(raw_data, data_type):
|
||||
if not raw_data or "error" in raw_data:
|
||||
return {"error": "Invalid data provided"}
|
||||
@@ -477,6 +681,10 @@ def process_spotify_data(raw_data, data_type):
|
||||
return format_album_data(raw_data)
|
||||
elif data_type == "playlist":
|
||||
return format_playlist_data(raw_data)
|
||||
elif data_type == "artist_discography":
|
||||
return format_artist_discography_data(raw_data)
|
||||
elif data_type == "artist":
|
||||
return format_artist_data(raw_data)
|
||||
else:
|
||||
return {"error": "Invalid data type"}
|
||||
except Exception as e:
|
||||
@@ -495,11 +703,23 @@ if __name__ == '__main__':
|
||||
album = "https://open.spotify.com/album/6J84szYCnMfzEcvIcfWMFL"
|
||||
song = "https://open.spotify.com/track/7so0lgd0zP2Sbgs2d7a1SZ"
|
||||
|
||||
artist_discography_all = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/all"
|
||||
artist_discography_albums = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/album"
|
||||
artist_discography_singles = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/single"
|
||||
artist_discography_compilations = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/compilation"
|
||||
|
||||
print("=== Testing Artist Discography (All) ===")
|
||||
filtered_discography = get_filtered_data(artist_discography_all, batch=True, delay=0.1)
|
||||
print(json.dumps(filtered_discography, indent=2))
|
||||
|
||||
print("\n=== Testing Playlist ===")
|
||||
filtered_playlist = get_filtered_data(playlist, batch=True, delay=0.1)
|
||||
print(json.dumps(filtered_playlist, indent=2))
|
||||
|
||||
print("\n=== Testing Album ===")
|
||||
filtered_album = get_filtered_data(album)
|
||||
print(json.dumps(filtered_album, indent=2))
|
||||
|
||||
print("\n=== Testing Track ===")
|
||||
filtered_track = get_filtered_data(song)
|
||||
print(json.dumps(filtered_track, indent=2))
|
||||
-251
@@ -1,251 +0,0 @@
|
||||
import requests
|
||||
import time
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.id3 import PictureType
|
||||
|
||||
class ProgressCallback:
|
||||
def __call__(self, current, total):
|
||||
if total > 0:
|
||||
percent = (current / total) * 100
|
||||
print(f"\r{percent:.2f}% ({current}/{total})", end="")
|
||||
else:
|
||||
print(f"\r{current / (1024 * 1024):.2f} MB", end="")
|
||||
|
||||
class QobuzDownloader:
|
||||
def __init__(self, region="us", timeout=30):
|
||||
if region not in ["eu", "us"]:
|
||||
raise ValueError("Region must be either 'us' or 'eu'")
|
||||
|
||||
self.region = region
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self.headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
self.base_api_url = f"https://{region}.qobuz.squid.wtf/api"
|
||||
self.download_chunk_size = 256 * 1024
|
||||
self.progress_callback = ProgressCallback()
|
||||
|
||||
def set_progress_callback(self, callback):
|
||||
self.progress_callback = callback
|
||||
|
||||
def sanitize_filename(self, filename):
|
||||
if not filename:
|
||||
return "Unknown Track"
|
||||
sanitized = re.sub(r'[\\/*?:"<>|]', "", str(filename))
|
||||
return re.sub(r'\s+', ' ', sanitized).strip() or "Unnamed Track"
|
||||
|
||||
def get_track_info(self, isrc):
|
||||
print(f"Fetching: {isrc}")
|
||||
search_url = f"{self.base_api_url}/get-music"
|
||||
params = {'q': isrc, 'offset': 0, 'limit': 10}
|
||||
|
||||
try:
|
||||
response = self.session.get(search_url, params=params, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
selected_track = None
|
||||
if data and data.get("success"):
|
||||
items = data.get("data", {}).get("tracks", {}).get("items", [])
|
||||
priority = {24: 1, 16: 2}
|
||||
for track in items:
|
||||
if track.get("isrc") == isrc:
|
||||
current_prio = priority.get(track.get("maximum_bit_depth"), 3)
|
||||
if selected_track is None or current_prio < priority.get(selected_track.get("maximum_bit_depth"), 3):
|
||||
selected_track = track
|
||||
if current_prio == 1:
|
||||
break
|
||||
|
||||
if not selected_track:
|
||||
raise Exception(f"Track not found: {isrc}")
|
||||
|
||||
title = selected_track.get('title', 'Unknown')
|
||||
bit_depth = selected_track.get('maximum_bit_depth', 'Unknown')
|
||||
print(f"Found: {title} ({bit_depth}b)")
|
||||
return selected_track
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise Exception(f"Request error: {e}")
|
||||
except Exception as e:
|
||||
raise Exception(f"Error: {e}")
|
||||
|
||||
def get_download_url(self, track_id):
|
||||
print("Fetching URL...")
|
||||
download_api_url = f"{self.base_api_url}/download-music"
|
||||
params = {'track_id': track_id, 'quality': 27}
|
||||
|
||||
try:
|
||||
response = self.session.get(download_api_url, params=params, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data and data.get("success") and data.get("data", {}).get("url"):
|
||||
download_url = data["data"]["url"]
|
||||
print("URL found")
|
||||
return download_url
|
||||
else:
|
||||
error_msg = data.get('error', {}).get('message', 'Unknown API error')
|
||||
raise Exception(f"API error: {error_msg}")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise Exception(f"Request error: {e}")
|
||||
except Exception as e:
|
||||
raise Exception(f"Error: {e}")
|
||||
|
||||
def download(self, isrc, output_dir=".", is_paused_callback=None, is_stopped_callback=None):
|
||||
if output_dir != ".":
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
except OSError as e:
|
||||
raise Exception(f"Directory error: {e}")
|
||||
|
||||
track_info = self.get_track_info(isrc)
|
||||
track_id = track_info.get("id")
|
||||
|
||||
if not track_id:
|
||||
raise Exception("No track ID found")
|
||||
|
||||
artist_name = self.sanitize_filename(track_info.get('performer', {}).get('name'))
|
||||
track_title = self.sanitize_filename(track_info.get('title'))
|
||||
output_filename = os.path.join(output_dir, f"{artist_name} - {track_title}.flac")
|
||||
|
||||
if os.path.exists(output_filename):
|
||||
file_size = os.path.getsize(output_filename)
|
||||
if file_size > 0:
|
||||
print(f"File already exists: {output_filename} ({file_size / (1024 * 1024):.2f} MB)")
|
||||
return output_filename
|
||||
|
||||
download_url = self.get_download_url(track_id)
|
||||
temp_filename = output_filename + ".part"
|
||||
|
||||
print(f"Downloading...")
|
||||
try:
|
||||
response = self.session.get(download_url, timeout=900)
|
||||
response.raise_for_status()
|
||||
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
raise Exception("Download stopped")
|
||||
|
||||
while is_paused_callback and is_paused_callback():
|
||||
time.sleep(0.1)
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
raise Exception("Download stopped")
|
||||
|
||||
with open(temp_filename, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
downloaded_size = len(response.content)
|
||||
total_size = downloaded_size
|
||||
|
||||
if self.progress_callback:
|
||||
self.progress_callback(downloaded_size, total_size)
|
||||
|
||||
os.rename(temp_filename, output_filename)
|
||||
print("Download complete")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
if os.path.exists(temp_filename):
|
||||
os.remove(temp_filename)
|
||||
raise Exception(f"Download failed: {e}")
|
||||
except Exception as e:
|
||||
if os.path.exists(temp_filename):
|
||||
os.remove(temp_filename)
|
||||
raise Exception(f"File error: {e}")
|
||||
|
||||
print("Adding metadata...")
|
||||
try:
|
||||
self._embed_metadata(output_filename, track_info)
|
||||
print("Metadata saved")
|
||||
except Exception as e:
|
||||
print(f"Tagging failed: {e}")
|
||||
|
||||
print(f"Done")
|
||||
return output_filename
|
||||
|
||||
def _embed_metadata(self, filename, track_info):
|
||||
try:
|
||||
audio = FLAC(filename)
|
||||
audio.delete()
|
||||
audio.clear_pictures()
|
||||
|
||||
album_info = track_info.get('album', {})
|
||||
artist = track_info.get('performer', {}).get('name')
|
||||
|
||||
if track_info.get('title'):
|
||||
audio['TITLE'] = track_info['title']
|
||||
if artist:
|
||||
audio['ARTIST'] = artist
|
||||
if album_info.get('title'):
|
||||
audio['ALBUM'] = album_info['title']
|
||||
if album_info.get('artist', {}).get('name', artist):
|
||||
audio['ALBUMARTIST'] = album_info.get('artist', {}).get('name', artist)
|
||||
if track_info.get('track_number'):
|
||||
audio['TRACKNUMBER'] = str(track_info['track_number'])
|
||||
if track_info.get('release_date_original'):
|
||||
audio['DATE'] = track_info['release_date_original']
|
||||
try:
|
||||
audio['YEAR'] = str(datetime.strptime(track_info['release_date_original'], '%Y-%m-%d').year)
|
||||
except ValueError:
|
||||
pass
|
||||
if album_info.get('genre', {}).get('name'):
|
||||
audio['GENRE'] = album_info['genre']['name']
|
||||
if track_info.get('copyright'):
|
||||
audio['COPYRIGHT'] = track_info['copyright']
|
||||
if track_info.get('isrc'):
|
||||
audio['ISRC'] = track_info['isrc']
|
||||
if album_info.get('label', {}).get('name'):
|
||||
audio['ORGANIZATION'] = album_info['label']['name']
|
||||
|
||||
img_info = album_info.get('image', {})
|
||||
cover_url = img_info.get('large') or img_info.get('small') or img_info.get('thumbnail')
|
||||
if cover_url:
|
||||
try:
|
||||
img_response = self.session.get(cover_url, timeout=30)
|
||||
img_response.raise_for_status()
|
||||
mime_type = img_response.headers.get('Content-Type', 'image/jpeg').lower()
|
||||
if mime_type in ['image/jpeg', 'image/png']:
|
||||
picture = Picture()
|
||||
picture.data = img_response.content
|
||||
picture.type = PictureType.COVER_FRONT
|
||||
picture.mime = mime_type
|
||||
audio.add_picture(picture)
|
||||
print("Cover added")
|
||||
except Exception as e:
|
||||
print(f"Cover error: {str(e)}")
|
||||
|
||||
audio.save()
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Metadata error: {e}")
|
||||
|
||||
def main():
|
||||
print("=== QobuzDL - Qobuz Downloader ===")
|
||||
downloader = QobuzDownloader(region="us")
|
||||
|
||||
isrc = "USAT22409172"
|
||||
output_dir = "."
|
||||
|
||||
try:
|
||||
downloaded_file = downloader.download(isrc, output_dir)
|
||||
print(f"Success: File saved as {downloaded_file}")
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
import sys
|
||||
if sys.platform == "win32":
|
||||
import os
|
||||
os.system("chcp 65001 > nul")
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
PyQt6
|
||||
pyqt6-tools
|
||||
pyqtdarktheme
|
||||
requests
|
||||
mutagen
|
||||
pyotp
|
||||
packaging
|
||||
pyinstaller
|
||||
+1
-1
@@ -144,7 +144,7 @@ class TidalDownloader:
|
||||
|
||||
def get_download_url(self, track_id, quality="LOSSLESS"):
|
||||
print("Fetching URL...")
|
||||
download_api_url = f"https://hifi.401658.xyz/track/?id={track_id}&quality={quality}"
|
||||
download_api_url = f"https://tidal.401658.xyz/track/?id={track_id}&quality={quality}"
|
||||
|
||||
try:
|
||||
response = requests.get(download_api_url, timeout=self.timeout)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-us" viewBox="0 0 640 480">
|
||||
<path fill="#bd3d44" d="M0 0h640v480H0"/>
|
||||
<path stroke="#fff" stroke-width="37" d="M0 55.3h640M0 129h640M0 203h640M0 277h640M0 351h640M0 425h640"/>
|
||||
<path fill="#192f5d" d="M0 0h364.8v258.5H0"/>
|
||||
<marker id="us-a" markerHeight="30" markerWidth="30">
|
||||
<path fill="#fff" d="m14 0 9 27L0 10h28L5 27z"/>
|
||||
</marker>
|
||||
<path fill="none" marker-mid="url(#us-a)" d="m0 0 16 11h61 61 61 61 60L47 37h61 61 60 61L16 63h61 61 61 61 60L47 89h61 61 60 61L16 115h61 61 61 61 60L47 141h61 61 60 61L16 166h61 61 61 61 60L47 192h61 61 60 61L16 218h61 61 61 61 60z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 648 B |
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "4.3"
|
||||
"version": "4.6"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user