Compare commits

..

11 Commits

Author SHA1 Message Date
afkarxyz 55669ec45f v4.7 2025-10-05 03:59:50 +07:00
afkarxyz 3304b13828 Update README.md 2025-10-05 03:42:30 +07:00
afkarxyz 579bb1415a Merge pull request #61 from value1338/main
Add button to auto-delete already downloaded files and retry failed ones
2025-10-05 03:40:21 +07:00
afkarxyz f0e71261a5 Update version.json 2025-10-05 03:39:40 +07:00
value1338 e2ad51da34 included Skipped Songs for Removal from Playlist 2025-10-04 13:06:13 +02:00
value1338 9e403ab1ba deleted comments in the code 2025-10-03 20:21:12 +02:00
value1338 7058559ddc Add button to auto-delete already downloaded files and retry failed ones 2025-10-03 19:47:51 +02:00
afkarxyz 861f303a4f v4.6 2025-09-20 08:34:27 +07:00
afkarxyz 9a28e8bd94 v4.6 2025-09-20 08:32:12 +07:00
afkarxyz f75385c4e8 open.spotify.com/intl-pt 2025-09-16 15:37:54 +07:00
afkarxyz 2eac274ee0 v4.5 2025-09-11 13:28:49 +07:00
16 changed files with 412 additions and 1039 deletions
+3 -3
View File
@@ -3,10 +3,10 @@
![SpotiFLAC](https://github.com/user-attachments/assets/b4c4f403-edbd-4a71-b74b-c7d433d47d06)
<div align="center">
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Qobuz, Tidal, Deezer & Amazon Music.
<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.4/SpotiFLAC.exe)
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.6/SpotiFLAC.exe)
## Screenshots
@@ -16,7 +16,7 @@
![image](https://github.com/user-attachments/assets/f604dc04-4ee6-4084-b314-0be7cd5d7ef9)
![image](https://github.com/user-attachments/assets/40264f32-f2cf-4e91-b09d-fb628d9771f7)
![image](https://github.com/user-attachments/assets/1c3beda2-236b-4452-8afd-a2dfedf389e5)
## Lossless Audio Check
+178 -298
View File
@@ -20,11 +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 qobuzAutoDL import QobuzDownloader as QobuzAutoDownloader
from qobuzRegionDL import QobuzDownloader as QobuzRegionDownloader
from tidalDL import TidalDownloader
from deezerDL import DeezerDownloader
from amazonDL import LucidaDownloader
@dataclass
class Track:
@@ -59,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", qobuz_mode="auto"):
use_artist_subfolders=False, use_album_subfolders=False, service="tidal"):
super().__init__()
self.tracks = tracks
self.outpath = outpath
@@ -77,11 +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.qobuz_mode = qobuz_mode
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":
@@ -94,17 +91,10 @@ class DownloadWorker(QThread):
def run(self):
try:
if self.service == "qobuz":
if self.qobuz_mode == "auto":
downloader = QobuzAutoDownloader()
else:
downloader = QobuzRegionDownloader(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()
@@ -156,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"))
@@ -204,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}")
@@ -232,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)
@@ -280,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:
@@ -294,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)}",
@@ -304,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
@@ -356,33 +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", mode="auto"):
super().__init__()
self.region = region
self.mode = mode
def run(self):
try:
if self.mode == "auto":
response = requests.get("https://qobuz.squid.wtf", timeout=5)
else:
response = requests.get(f"https://{self.region}.qqdl.site", 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)
@@ -396,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)
@@ -453,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:
@@ -527,122 +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': 'us', 'name': 'USA', 'icon': 'us.svg', 'online': False},
{'id': 'eu', 'name': 'Europe', 'icon': 'eu.svg', 'online': False},
{'id': 'br', 'name': 'Brazil', 'icon': 'br.svg', 'online': False},
{'id': 'jp', 'name': 'Japan', 'icon': 'jp.svg', 'online': False},
{'id': 'au', 'name': 'Australia', 'icon': 'au.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, "region")
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.5"
self.current_version = "4.7"
self.tracks = []
self.all_tracks = []
self.successful_downloads = []
self.reset_state()
self.settings = QSettings('SpotiFLAC', 'Settings')
@@ -654,8 +470,6 @@ 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.qobuz_mode = self.settings.value('qobuz_mode', 'auto')
self.check_for_updates = self.settings.value('check_for_updates', True, type=bool)
self.current_theme_color = self.settings.value('theme_color', '#2196F3')
self.track_list_format = self.settings.value('track_list_format', 'track_artist_date_duration')
@@ -1026,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)
@@ -1041,6 +861,7 @@ 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()
@@ -1211,29 +1032,6 @@ class SpotiFLACGUI(QWidget):
service_fallback_layout.addWidget(service_label)
service_fallback_layout.addWidget(self.service_dropdown)
service_fallback_layout.addSpacing(10)
self.qobuz_mode_label = QLabel('Mode:')
self.qobuz_mode_dropdown = QComboBox()
self.qobuz_mode_dropdown.addItem("Auto", "auto")
self.qobuz_mode_dropdown.addItem("Region", "region")
self.qobuz_mode_dropdown.currentIndexChanged.connect(self.on_qobuz_mode_changed)
service_fallback_layout.addWidget(self.qobuz_mode_label)
service_fallback_layout.addWidget(self.qobuz_mode_dropdown)
service_fallback_layout.addSpacing(10)
self.region_label = QLabel('Region:')
self.qobuz_region_dropdown = QobuzRegionComboBox()
self.qobuz_region_dropdown.currentIndexChanged.connect(self.save_qobuz_region_setting)
service_fallback_layout.addWidget(self.region_label)
service_fallback_layout.addWidget(self.qobuz_region_dropdown)
self.qobuz_mode_label.hide()
self.qobuz_mode_dropdown.hide()
self.region_label.hide()
self.qobuz_region_dropdown.hide()
service_fallback_layout.addStretch()
auth_layout.addLayout(service_fallback_layout)
@@ -1242,17 +1040,9 @@ 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.set_combobox_value(self.qobuz_mode_dropdown, self.qobuz_mode)
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.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)
)
def setup_theme_tab(self):
theme_tab = QWidget()
theme_layout = QVBoxLayout()
@@ -1454,7 +1244,7 @@ class SpotiFLACGUI(QWidget):
about_layout.addWidget(section_widget)
footer_label = QLabel(f"v{self.current_version} | September 2025")
footer_label = QLabel(f"v{self.current_version} | October 2025")
about_layout.addWidget(footer_label, alignment=Qt.AlignmentFlag.AlignCenter)
about_tab.setLayout(about_layout)
@@ -1465,44 +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 on_qobuz_mode_changed(self, index):
mode = self.qobuz_mode_dropdown.currentData()
self.qobuz_mode = mode
self.settings.setValue('qobuz_mode', mode)
self.settings.sync()
self.update_qobuz_mode_ui()
self.log_output.append(f"Qobuz mode changed to: {self.qobuz_mode_dropdown.currentText()}")
def update_service_ui(self):
service = self.service
if service == "qobuz":
self.qobuz_mode_label.show()
self.qobuz_mode_dropdown.show()
self.update_qobuz_mode_ui()
else:
self.qobuz_mode_label.hide()
self.qobuz_mode_dropdown.hide()
self.region_label.hide()
self.qobuz_region_dropdown.hide()
def update_qobuz_mode_ui(self):
mode = self.qobuz_mode_dropdown.currentData()
if mode is None:
mode = self.qobuz_mode
if mode == "region":
self.region_label.show()
self.qobuz_region_dropdown.show()
else:
self.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()
@@ -1532,13 +1286,6 @@ 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)
self.settings.sync()
self.log_output.append(f"Qobuz region setting saved: {self.qobuz_region_dropdown.currentText()}")
def save_track_list_format(self):
format_value = self.track_list_format_dropdown.currentData()
self.track_list_format = format_value
@@ -1596,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)
@@ -1695,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)
@@ -1753,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()
@@ -1855,8 +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"
qobuz_mode = self.qobuz_mode_dropdown.currentData() if service == "qobuz" else "auto"
self.worker = DownloadWorker(
tracks_to_download,
@@ -1869,11 +1694,9 @@ class SpotiFLACGUI(QWidget):
self.use_track_numbers,
self.use_artist_subfolders,
self.use_album_subfolders,
service,
qobuz_region,
qobuz_mode
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()
@@ -1905,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)
@@ -1944,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()
@@ -1988,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
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

-128
View File
@@ -1,128 +0,0 @@
import requests
import time
import os
import re
import base64
import urllib3
from urllib.parse import unquote
from random import randrange
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
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)}"
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': get_random_user_agent()}
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"Fetching: {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("Fetching URL...")
while True:
resp = client.get(completion_url, headers=headers).json()
if resp["status"] == "completed":
print("URL found")
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"\r{percent}%", end="")
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...")
with open(file_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
print("Download complete")
print("Done")
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):
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__":
print("=== AmazonDL - Amazon Music Downloader ===")
track_id = "2plbrEY59IikOBgBGLjaoe"
service = "amazon"
download_track(track_id, service)
-8
View File
@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-au" viewBox="0 0 640 480">
<path fill="#00008B" d="M0 0h640v480H0z"/>
<path fill="#fff" d="m37.5 0 122 90.5L281 0h39v31l-120 89.5 120 89V240h-40l-120-89.5L40.5 240H0v-30l119.5-89L0 32V0z"/>
<path fill="red" d="M212 140.5 320 220v20l-135.5-99.5zm-92 10 3 17.5-96 72H0zM320 0v1.5l-124.5 94 1-22L295 0zM0 0l119.5 88h-30L0 21z"/>
<path fill="#fff" d="M120.5 0v240h80V0zM0 80v80h320V80z"/>
<path fill="red" d="M0 96.5v48h320v-48zM136.5 0v240h48V0z"/>
<path fill="#fff" d="m527 396.7-20.5 2.6 2.2 20.5-14.8-14.4-14.7 14.5 2-20.5-20.5-2.4 17.3-11.2-10.9-17.5 19.6 6.5 6.9-19.5 7.1 19.4 19.5-6.7-10.7 17.6zm-3.7-117.2 2.7-13-9.8-9 13.2-1.5 5.5-12.1 5.5 12.1 13.2 1.5-9.8 9 2.7 13-11.6-6.6zm-104.1-60-20.3 2.2 1.8 20.3-14.4-14.5-14.8 14.1 2.4-20.3-20.2-2.7 17.3-10.8-10.5-17.5 19.3 6.8L387 178l6.7 19.3 19.4-6.3-10.9 17.3 17.1 11.2ZM623 186.7l-20.9 2.7 2.3 20.9-15.1-14.7-15 14.8 2.1-21-20.9-2.4 17.7-11.5-11.1-17.9 20 6.7 7-19.8 7.2 19.8 19.9-6.9-11 18zm-96.1-83.5-20.7 2.3 1.9 20.8-14.7-14.8-15.1 14.4 2.4-20.7-20.7-2.8 17.7-11L467 73.5l19.7 6.9 7.3-19.5 6.8 19.7 19.8-6.5-11.1 17.6zM234 385.7l-45.8 5.4 4.6 45.9-32.8-32.4-33 32.2 4.9-45.9-45.8-5.8 38.9-24.8-24-39.4 43.6 15 15.8-43.4 15.5 43.5 43.7-14.7-24.3 39.2 38.8 25.1Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-45
View File
@@ -1,45 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-br" viewBox="0 0 640 480">
<g stroke-width="1pt">
<path fill="#229e45" fill-rule="evenodd" d="M0 0h640v480H0z"/>
<path fill="#f8e509" fill-rule="evenodd" d="m321.4 436 301.5-195.7L319.6 44 17.1 240.7z"/>
<path fill="#2b49a3" fill-rule="evenodd" d="M452.8 240c0 70.3-57.1 127.3-127.6 127.3A127.4 127.4 0 1 1 452.8 240"/>
<path fill="#ffffef" fill-rule="evenodd" d="m283.3 316.3-4-2.3-4 2 .9-4.5-3.2-3.4 4.5-.5 2.2-4 1.9 4.2 4.4.8-3.3 3m86 26.3-3.9-2.3-4 2 .8-4.5-3.1-3.3 4.5-.5 2.1-4.1 2 4.2 4.4.8-3.4 3.1m-36.2-30-3.4-2-3.5 1.8.8-3.9-2.8-2.9 4-.4 1.8-3.6 1.6 3.7 3.9.7-3 2.7m87-8.5-3.4-2-3.5 1.8.8-3.9-2.7-2.8 3.9-.4 1.8-3.5 1.6 3.6 3.8.7-2.9 2.6m-87.3-22-4-2.2-4 2 .8-4.6-3.1-3.3 4.5-.5 2.1-4.1 2 4.2 4.4.8-3.4 3.2m-104.6-35-4-2.2-4 2 1-4.6-3.3-3.3 4.6-.5 2-4.1 2 4.2 4.4.8-3.3 3.1m13.3 57.2-4-2.3-4 2 .9-4.5-3.2-3.3 4.5-.6 2.1-4 2 4.2 4.4.8-3.3 3.1m132-67.3-3.6-2-3.6 1.8.8-4-2.8-3 4-.5 1.9-3.6 1.7 3.8 4 .7-3 2.7m-6.7 38.3-2.7-1.6-2.9 1.4.6-3.2-2.2-2.3 3.2-.4 1.5-2.8 1.3 3 3 .5-2.2 2.2m-142.2 50.4-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2M419 299.8l-2.2-1.1-2.2 1 .5-2.3-1.7-1.6 2.4-.3 1.2-2 1 2 2.5.5-1.9 1.5"/>
<path fill="#ffffef" fill-rule="evenodd" d="m219.3 287.6-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2"/>
<path fill="#ffffef" fill-rule="evenodd" d="m219.3 287.6-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m42.3 3-2.6-1.4-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .5-2.3 2.1m-4.8 17-2.6-1.5-2.7 1.4.6-3-2.1-2.3 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m87.4-22.2-2.6-1.6-2.8 1.4.6-3-2-2.3 3-.3 1.4-2.7 1.2 2.8 3 .5-2.2 2.1m-25.1 3-2.7-1.5-2.7 1.4.6-3-2-2.3 3-.3 1.4-2.8 1.2 2.9 3 .5-2.2 2.1m-68.8-5.8-1.7-1-1.7.8.4-1.9-1.3-1.4 1.9-.2.8-1.7.8 1.8 1.9.3-1.4 1.3m167.8 45.4-2.6-1.5-2.7 1.4.6-3-2.1-2.3 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m-20.8 6-2.2-1.4-2.3 1.2.5-2.6-1.7-1.8 2.5-.3 1.2-2.3 1 2.4 2.5.4-1.9 1.8m10.4 2.3-2-1.2-2.1 1 .4-2.3-1.6-1.7 2.3-.3 1.1-2 1 2 2.3.5-1.7 1.6m29.1-22.8-2-1-2 1 .5-2.3-1.6-1.7 2.3-.3 1-2 1 2.1 2.1.4-1.6 1.6m-38.8 41.8-2.5-1.4-2.7 1.2.6-2.8-2-2 3-.3 1.3-2.5 1.2 2.6 3 .5-2.3 1.9m.6 14.2-2.4-1.4-2.4 1.3.6-2.8-1.9-2 2.7-.4 1.2-2.5 1.1 2.6 2.7.5-2 2m-19-23.1-1.9-1.2-2 1 .4-2.2-1.5-1.7 2.2-.2 1-2 1 2 2.2.4-1.6 1.6m-17.8 2.3-2-1.2-2 1 .5-2.2-1.6-1.7 2.3-.2 1-2 1 2 2.1.4-1.6 1.6m-30.4-24.6-2-1.1-2 1 .5-2.3-1.6-1.6 2.2-.3 1-2 1 2 2.2.5-1.6 1.5m3.7 57-1.6-.9-1.8.9.4-2-1.3-1.4 1.9-.2.9-1.7.8 1.8 1.9.3-1.4 1.3m-46.2-86.6-4-2.3-4 2 .9-4.5-3.2-3.3 4.5-.6 2.2-4 1.9 4.2 4.4.8-3.3 3.1"/>
<path fill="#fff" fill-rule="evenodd" d="M444.4 285.8a125 125 0 0 0 5.8-19.8c-67.8-59.5-143.3-90-238.7-83.7a125 125 0 0 0-8.5 20.9c113-10.8 196 39.2 241.4 82.6"/>
<path fill="#309e3a" d="m414 252.4 2.3 1.3a3 3 0 0 0-.3 2.2 3 3 0 0 0 1.4 1.7q1 .8 2 .7.9 0 1.3-.7l.2-.9-.5-1-1.5-1.8a8 8 0 0 1-1.8-3 4 4 0 0 1 2-4.4 4 4 0 0 1 2.3-.2 7 7 0 0 1 2.6 1.2q2.1 1.5 2.6 3.2a4 4 0 0 1-.6 3.3l-2.4-1.5q.5-1 .2-1.7-.2-.8-1.2-1.4a3 3 0 0 0-1.8-.7 1 1 0 0 0-.9.5q-.3.4-.1 1 .2.8 1.6 2.2t2 2.5a4 4 0 0 1-.3 4.2 4 4 0 0 1-1.9 1.5 4 4 0 0 1-2.4.3q-1.3-.3-2.8-1.3-2.2-1.5-2.7-3.3a5 5 0 0 1 .6-4zm-11.6-7.6 2.5 1.3a3 3 0 0 0-.2 2.2 3 3 0 0 0 1.4 1.6q1.1.8 2 .6.9 0 1.3-.8l.2-.8q0-.5-.5-1l-1.6-1.8q-1.7-1.6-2-2.8a4 4 0 0 1 .4-3.1 4 4 0 0 1 1.6-1.4 4 4 0 0 1 2.2-.3 7 7 0 0 1 2.6 1q2.3 1.5 2.7 3.1a4 4 0 0 1-.4 3.4l-2.5-1.4q.5-1 .2-1.7-.4-1-1.3-1.4a3 3 0 0 0-1.9-.6 1 1 0 0 0-.8.5q-.3.4-.1 1 .3.8 1.7 2.2 1.5 1.5 2 2.4a4 4 0 0 1 0 4.2 4 4 0 0 1-1.8 1.6 4 4 0 0 1-2.4.3 8 8 0 0 1-2.9-1.1 6 6 0 0 1-2.8-3.2 5 5 0 0 1 .4-4m-14.2-3.8 7.3-12 8.8 5.5-1.2 2-6.4-4-1.6 2.7 6 3.7-1.3 2-6-3.7-2 3.3 6.7 4-1.2 2zm-20.7-17 1.1-2 5.4 2.7-2.5 5q-1.2.3-3 .2a9 9 0 0 1-3.3-1 8 8 0 0 1-3-2.6 6 6 0 0 1-1-3.5 9 9 0 0 1 1-3.7 8 8 0 0 1 2.6-3 6 6 0 0 1 3.6-1.1q1.4 0 3.2 1 2.4 1.1 3.1 2.8a5 5 0 0 1 .3 3.5l-2.7-.8a3 3 0 0 0-.2-2q-.4-.9-1.6-1.4a4 4 0 0 0-3.1-.3q-1.5.5-2.6 2.6t-.7 3.8a4 4 0 0 0 2 2.4q.8.5 1.7.5h1.8l.8-1.6zm-90.2-22.3 2-14 4.2.7 1.1 9.8 3.9-9 4.2.6-2 13.8-2.7-.4 1.7-10.9-4.4 10.5-2.7-.4-1.1-11.3-1.6 11zm-14.1-1.7 1.3-14 10.3 1-.2 2.4-7.5-.7-.3 3 7 .7-.3 2.4-7-.7-.3 3.8 7.8.7-.2 2.4z"/>
<g stroke-opacity=".5">
<path fill="#309e3a" d="M216.5 191.3q0-2.2.7-3.6a7 7 0 0 1 1.4-1.9 5 5 0 0 1 1.8-1.2q1.5-.5 3-.5 3.1.1 5 2a7 7 0 0 1 1.6 5.5q0 3.3-2 5.3a7 7 0 0 1-5 1.7 7 7 0 0 1-4.8-2 7 7 0 0 1-1.7-5.3"/>
<path fill="#f7ffff" d="M219.4 191.3q0 2.3 1 3.6t2.8 1.3a4 4 0 0 0 2.8-1.1q1-1.2 1.1-3.7.1-2.4-1-3.6a4 4 0 0 0-2.7-1.3 4 4 0 0 0-2.8 1.2q-1.1 1.2-1.2 3.6"/>
</g>
<g stroke-opacity=".5">
<path fill="#309e3a" d="m233 198.5.2-14h6q2.2 0 3.2.5 1 .3 1.6 1.3c.6 1 .6 1.4.6 2.3a4 4 0 0 1-1 2.6 5 5 0 0 1-2.7 1.2l1.5 1.2q.6.6 1.5 2.3l1.7 2.8h-3.4l-2-3.2-1.4-2-.9-.6-1.4-.2h-.6v5.8z"/>
<path fill="#fff" d="M236 190.5h2q2.1 0 2.6-.2.5-.1.8-.5.4-.6.3-1 0-.9-.4-1.2-.3-.4-1-.6h-2l-2.3-.1z"/>
</g>
<g stroke-opacity=".5">
<path fill="#309e3a" d="m249 185.2 5.2.3q1.7 0 2.6.3a5 5 0 0 1 2 1.4 6 6 0 0 1 1.2 2.4q.4 1.4.3 3.3a9 9 0 0 1-.5 3q-.6 1.5-1.7 2.4a5 5 0 0 1-2 1q-1 .3-2.5.2l-5.3-.3z"/>
<path fill="#fff" d="m251.7 187.7-.5 9.3h3.8q.8 0 1.2-.5.5-.4.8-1.3t.4-2.6l-.1-2.5a3 3 0 0 0-.8-1.4l-1.2-.7-2.3-.3z"/>
</g>
<g stroke-opacity=".5">
<path fill="#309e3a" d="m317.6 210.2 3.3-13.6 4.4 1 3.2 1q1.1.6 1.6 1.9t.2 2.8q-.3 1.2-1 2a4 4 0 0 1-3 1.4q-1 0-3-.5l-1.7-.5-1.2 5.2z"/>
<path fill="#fff" d="m323 199.6-.8 3.8 1.5.4q1.6.4 2.2.3a2 2 0 0 0 1.6-1.5q0-.7-.2-1.3a2 2 0 0 0-1-.9l-1.9-.5-1.3-.3z"/>
</g>
<g stroke-opacity=".5">
<path fill="#309e3a" d="m330.6 214.1 4.7-13.2 5.5 2q2.2.8 3 1.4.8.7 1 1.8c.2 1.1.2 1.5 0 2.3q-.6 1.5-1.8 2.2-1.2.6-3 .3.6.7 1 1.6l.8 2.7.6 3.1-3.1-1.1-1-3.6-.7-2.4-.6-.8q-.3-.4-1.3-.7l-.5-.2-2 5.6z"/>
<path fill="#fff" d="m336 207.4 1.9.7q2 .7 2.5.7t.9-.3q.5-.3.6-.9.3-.6 0-1.2l-.8-.9-2-.7-2-.7-1.2 3.3z"/>
</g>
<g stroke-opacity=".5">
<path fill="#309e3a" d="M347 213.6a9 9 0 0 1 1.7-3.2l1.8-1.5 2-.7q1.5-.1 3.1.4a7 7 0 0 1 4.2 3.3q1.2 2.4.2 5.7a7 7 0 0 1-3.4 4.5q-2.3 1.3-5.2.4a7 7 0 0 1-4.2-3.3 7 7 0 0 1-.2-5.6"/>
<path fill="#fff" d="M349.8 214.4q-.7 2.3 0 3.8c.7 1.5 1.2 1.6 2.3 2q1.5.5 3-.4 1.4-.8 2.1-3.2.8-2.2 0-3.7a4 4 0 0 0-2.2-2 4 4 0 0 0-3 .3q-1.5.8-2.2 3.2"/>
</g>
<g stroke-opacity=".5">
<path fill="#309e3a" d="m374.3 233.1 6.4-12.4 5.3 2.7a10 10 0 0 1 2.7 1.9q.8.7.8 1.9c0 1.2 0 1.5-.4 2.2a4 4 0 0 1-2 2q-1.5.4-3.1-.2.6 1 .8 1.7.3.9.4 2.8l.2 3.2-3-1.5-.4-3.7-.3-2.5-.5-1-1.2-.7-.5-.3-2.7 5.2z"/>
<path fill="#fff" d="m380.5 227.2 1.9 1q1.8 1 2.3 1t1-.2q.4-.2.7-.8t.2-1.2l-.7-1-1.8-1-2-1z"/>
</g>
<g stroke-opacity=".5">
<path fill="#309e3a" d="M426.1 258.7a9 9 0 0 1 2.5-2.6 7 7 0 0 1 2.2-.9 6 6 0 0 1 2.2 0q1.5.3 2.8 1.2a7 7 0 0 1 3 4.4q.4 2.6-1.4 5.5a7 7 0 0 1-4.5 3.3 7 7 0 0 1-5.2-1.1 7 7 0 0 1-3-4.4q-.4-2.7 1.4-5.4"/>
<path fill="#fff" d="M428.6 260.3q-1.4 2-1.1 3.6a4 4 0 0 0 1.6 2.5q1.5 1 3 .6t2.9-2.4q1.4-2.1 1.1-3.6t-1.6-2.6c-1.4-1.1-2-.8-3-.5q-1.5.3-3 2.4z"/>
</g>
<path fill="#309e3a" d="m301.8 204.5 2.3-9.8 7.2 1.7-.3 1.6-5.3-1.2-.5 2.2 4.9 1.1-.4 1.7-4.9-1.2-.6 2.7 5.5 1.3-.4 1.6z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.0 KiB

-28
View File
@@ -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

+221 -1
View File
@@ -57,6 +57,8 @@ 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': get_random_user_agent(),
'Accept': 'application/json',
@@ -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))
-11
View File
@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-jp" viewBox="0 0 640 480">
<defs>
<clipPath id="jp-a">
<path fill-opacity=".7" d="M-88 32h640v480H-88z"/>
</clipPath>
</defs>
<g fill-rule="evenodd" stroke-width="1pt" clip-path="url(#jp-a)" transform="translate(88 -32)">
<path fill="#fff" d="M-128 32h720v480h-720z"/>
<circle cx="523.1" cy="344.1" r="194.9" fill="#bc002d" transform="translate(-168.4 8.6)scale(.76554)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 470 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

-251
View File
@@ -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
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 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, timeout=30):
self.timeout = timeout
self.session = requests.Session()
self.headers = {
'User-Agent': get_random_user_agent()
}
self.base_api_url = "https://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, 'region': 'auto'}
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, 'region': 'auto'}
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 (Auto) ===")
downloader = QobuzDownloader()
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()
-255
View File
@@ -1,255 +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
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 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 ["us", "eu", "br", "jp", "au"]:
raise ValueError("Region must be one of: 'us', 'eu', 'br', 'jp', 'au'")
self.region = region
self.timeout = timeout
self.session = requests.Session()
self.headers = {
'User-Agent': get_random_user_agent()
}
self.base_api_url = f"https://{region}.qqdl.site/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 (Region) ===")
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()
+8
View File
@@ -0,0 +1,8 @@
PyQt6
pyqt6-tools
pyqtdarktheme
requests
mutagen
pyotp
packaging
pyinstaller
+1 -1
View File
@@ -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)
-9
View File
@@ -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
View File
@@ -1,3 +1,3 @@
{
"version": "4.4"
"version": "4.6"
}